From a7b7019c198fa0963bfe171fb5e5963f1f8d0bf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:46:12 +0000 Subject: [PATCH 01/14] Plan staged patch rollout for v5 Adds the design for rolling Socket patches out gradually: a `patches:` block in socket.yml that narrows what scan may patch (paths, ecosystems, packages, severity floor, on/off), and a severity-ordered per-run cap on new patches so each scan lands the next few most critical fixes. The plan splits the work into two parallel items with a frozen interface, lists every hard-coded filter and where it belongs, and covers the depscan autopatch follow-up. configuration.md now records that socket-patch reads socket.yml for selection policy only, with the trust boundary unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/design/configuration.md | 62 ++- docs/design/staged-rollout.md | 810 ++++++++++++++++++++++++++++++++++ docs/design/v5-plan.md | 7 + 3 files changed, 865 insertions(+), 14 deletions(-) create mode 100644 docs/design/staged-rollout.md diff --git a/docs/design/configuration.md b/docs/design/configuration.md index 4bc48634..94a79bb0 100644 --- a/docs/design/configuration.md +++ b/docs/design/configuration.md @@ -1,8 +1,9 @@ # Configuration design: env vars, the socket-cli config file, and what we deliberately don't read -Status: **implemented** (v3.5). This document records the settled design so -future configuration surface grows inside it instead of inventing new -mechanisms. +Status: **implemented** (v3.5); section 4 (`socket.yml` patch policy) is +**planned** for v5.0 (see `staged-rollout.md`). This document records the +settled design so future configuration surface grows inside it instead of +inventing new mechanisms. ## Problem @@ -69,13 +70,46 @@ UX policy and are ignored. - The python `socketsecurity` CLI already accepts `SOCKET_API_TOKEN`, so the canonical names are the cross-tool bridge; no `SOCKET_SECURITY_*` aliases were added. -- `socket.yml` stays a scanning-product surface (projectIgnorePaths / - issueRules / githubApp); socket-patch does not read it. +- `socket.yml` is shared with the scanning product (projectIgnorePaths / + triggerPaths / issueRules / githubApp). As of v5.0 socket-patch reads + exactly two of its keys, `projectIgnorePaths` and a new `patches` block, + and nothing else (section 4). - `SOCKET_PROXY_URL` (the public patch **endpoint**) must never be conflated with socket-cli's `apiProxy` (an HTTP **forward proxy**). Forward-proxy behavior comes from the standard `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` vars, which reqwest honors. +### 4. `socket.yml` carries patch selection policy, never settings (v5.0) + +Revisits the v3.5 position that socket-patch does not read `socket.yml`. +Staged rollout needs a repo-owned, reviewable place to say which projects, +ecosystems and packages may be patched, a severity floor and a per-run cap +on new patches (`staged-rollout.md`). `socket.yml` is where Socket users +already express repo policy, it lives at the repo root, and a new +top-level `patches:` key is stripped or ignored by every existing parser. + +The trust boundary is unchanged and gains its positive half: + +- A repository file may **narrow or pace** what `scan` patches. It may + never widen it, name an endpoint or credential, choose a mode or download + format, or disable a safety interlock. The parser has no fields for any + of those; such keys are unknown keys and fail validation. +- Because the file only narrows, an invalid file fails closed (exit 1, + `socket_yml_invalid`, nothing written) instead of being treated as + absent. This is the opposite of the socket-cli `config.json` rule above + (corrupt → warn and ignore), and deliberately so: ignoring a broken + user-level login file loses a convenience; ignoring a broken repo policy + widens the rollout. +- Lookup is bounded to the repository (nearest `.git` ancestor of + `--cwd`, else `--cwd`), root files only. +- Flags and env vars still win over the file for scalars (CLI > env > + file > default) and intersect with it for list filters; + `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` ignores the file. +- Only `scan` (every mode) and the in-memory engine honor it. Commands + that report, attest or undo existing state (`list`, `vex`, `rollback`, + `remove`, `repair`, `apply`, `vendor`) ignore it; `get` bypasses it with + a warning. + ## Explicitly rejected | Idea | Why not | @@ -83,21 +117,21 @@ UX policy and are ignored. | Auto-loading `.env` / `.env.local` | Trust boundary: the tool mutates installed packages while holding an API token; a file in a *cloned repo* must never redirect endpoints, disable interlocks, or spend the token. Also the wrong convention class — npm/cargo/pip/git read no `.env`; dotenv is an app-runtime convention. Users who want it have direnv/mise/dotenvx. | | A new socket-patch config file (`.socket/config.toml`, …) | Duplicates socket-cli's persisted config; one more file format to trust, document, and migrate. | | Writing to socket-cli's `config.json` | No login flow here; shared mutable state and format drift for zero benefit. | -| Honoring endpoints/credentials from repo-level files (manifest, socket.yml) | Same trust boundary as `.env`. Stated as a contract property in `CLI_CONTRACT.md`. | +| Honoring endpoints/credentials/interlock switches from repo-level files (manifest, socket.yml) | Same trust boundary as `.env`. Stated as a contract property in `CLI_CONTRACT.md`. Selection policy that only narrows is the one exception (section 4). | +| A `version: 3` socket.yml for the `patches` block | socket-cli rejects any version but 2; older ajv parsers would treat 3 as 2 anyway. The block is additive under `version: 2`. | +| Per-directory `socket.yml` files | No existing consumer supports them; one root file with `includePaths` covers monorepos. | | `SOCKET_CLI_CONFIG` (ephemeral full-JSON config override) | Imports socket-cli's whole config vocabulary as a permanent compat contract. | | Mapping `apiProxy` → anything | Forward-proxy vs patch-endpoint semantic trap; `HTTP_PROXY` et al. already work. | | `enforcedOrgs` / `skipAskToPersistDefaultOrg` | Interactive socket-cli UX policy with no socket-patch analog. | ## Deferred (designated homes, no implementation yet) -- **Project-level behavioral defaults** (`ecosystems`, `downloadMode`, - `vendorSource`): if demand materializes, they go in the manifest `setup` - block (`setup.defaults`, camelCase) — the manifest already controls what - gets patched, so behavioral defaults there grant no new capability, and - the serde struct simply has no fields for URLs/credentials/interlocks. - Requires teaching the TS zod twin - (`npm/socket-patch/src/schema/manifest-schema.ts`) to model `setup`. - Precedence would be flag > env > `setup.defaults` > default. +- **Project-level behavioral defaults** (`downloadMode`, `vendorSource`, + mode): not planned. The v3.5 idea of a manifest `setup.defaults` block is + obsolete in v5 (hosted and vendored projects have no manifest and `setup` + is removed). Selection policy (ecosystems, packages, paths, severity, + per-run cap) went to `socket.yml` `patches` instead (section 4). Anything + that is not pure narrowing stays out of repo files. - **Env cleanup sweep**: core's direct env readers (`SOCKET_OFFLINE` in `utils/env_compat.rs`, `SOCKET_TELEMETRY_DISABLED` in `telemetry.rs`) still match only `1|true`, unlike `parse_bool_flag`'s vocabulary (the CLI diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md new file mode 100644 index 00000000..88b28511 --- /dev/null +++ b/docs/design/staged-rollout.md @@ -0,0 +1,810 @@ +# Staged patch rollout: `socket.yml` patch policy + `scan --max-new-patches` + +Status: **planned** (v5.0). Target branch `release/v5-prerelease`. +Two work items, built in parallel: **A** (policy file + filters) and +**B** (per-run limit + ordering + reporting). Section 9 specifies both. + +## 1. Goal + +Make it easy to roll Socket patches out gradually: + +1. **Repo policy in `socket.yml`.** Say which projects, ecosystems and + packages socket-patch may patch, and a severity floor. Defaults apply + when the file or the block is absent. The hard-coded repo-path filter + that exists today moves here as an overridable default. +2. **A per-run cap on new patches.** `scan` adds at most N patches to + packages that have none yet, most severe first. Repeated runs converge: + each run lands the next N. + +Non-goals: open-PR accounting (a CLI patching a working tree has no PR +state; that stays in depscan), schedules, release-age cooldowns (security +fixes are exempt from cooldowns in Dependabot and Renovate too), and +per-directory `socket.yml` files. + +## 2. What exists today (research summary) + +### 2.1 `socket.yml` v2 and its consumers + +| Parser | Where | Unknown top-level keys | Wrong type on a known key | +|---|---|---|---| +| P1 `@socketsecurity/config` 3.0.1 (archived; bundled in `socket` 1.x) | `socket-config-js/index.js:30-88` | stripped (ajv `removeAdditional: 'failing'`, `additionalProperties: false` at top level and under `githubApp`) | file rejected | +| P2 depscan copy (GitHub App, fix-PR) | `workspaces/lib/src/config-js/socket-yaml-schema.ts`, `parse-socket-yaml.ts` | stripped (same ajv options) | whole file rejected; PR check goes neutral with "error processing the socket.yml" (`diff-report-runner/index.ts:441-464`) | +| P3 socket-cli 2.x | `src/util/socket-yaml.mts` | ignored (hand-written picker) | that key dropped | +| P4 Coana | not available | unverified | reads `projectIgnorePaths` only | + +- Keys in the wild: `version` (integer; P1/P2 accept any integer, P3 + rejects anything but 2), `projectIgnorePaths`, `triggerPaths`, + `issueRules`, `githubApp.*`. Nothing mentions patches. +- **A new top-level `patches:` key breaks no parser** as long as the file + keeps `version: 2`. P1/P2 strip it, P3 ignores it. None of them will + ever see it; socket-patch is its only reader. +- Lookup: the GitHub App reads only the repo-root `socket.yml` / + `socket.yaml` at the scanned commit, and when both exist `socket.yaml` + wins (git tree order, `get-socket-repo-config.ts:96-120`). socket-cli + walks up from cwd and prefers `socket.yml`. The docs say `socket.yml` + wins. Nobody merges multiple files; there are no per-directory files. +- Glob semantics (backend): the `ignore` npm package, i.e. **gitignore + rules**, case-insensitive (`list-files.ts:476-507`). A leading `/` or a + middle `/` anchors to the repo root, a bare name matches at any depth, a + trailing `/` matches directories only, `!` negates, last match wins, a + child of an excluded directory cannot be re-included. +- An unquoted `**` entry is a YAML error. Case-insensitivity and the + "excluded parent" rule are the two things users trip over. + +### 2.2 socket-patch v5 today + +- Selection per package: `socket_patch_core::api::ranking` + (`ranking.rs:85`): merged patches (>= 2 advisories) newest first, then + severity, then publish date, then tier/uuid. `RankKey.severity` is forced + to 0 for merged patches, so it is **not** the patch's real severity. + `severity_order` (`ranking.rs:41`) and `max_severity_order` are. +- Per-patch data: severity (max, uppercase), advisory ids, tier, optional + `publishedAt` (batch endpoint usually lacks it). No CVSS, EPSS, KEV, + reachability or direct/transitive information anywhere. +- Scan selects patches in five disk call sites plus one in the in-memory + engine: `discover_selected` (`scan/mod.rs:553`, called from `mod.rs:1999`, + `hosted.rs:1063`, `vendor_flow.rs:459`, `mod.rs:2349`), the human + agent/vendored arm (`mod.rs:2386-2402` via `get.rs:1097`), and + `hosted_memory/discover.rs:286` (`select_top_ranked`, called at + `hosted_memory/mod.rs:537`). +- Existing filters: `--ecosystems`, `--package` (`package_spec_matches`, + `mod.rs:383`), PATH globs (`path_scope.rs`), all applied after the prune + universe is captured (`mod.rs:1480`). +- Recorded state: `merge_ledger_records_for_updates` (`discovery.rs:412`, + manifest > hosted lockfile pins > vendor ledger) and `detect_updates` + (`discovery.rs:452`) with `batch_supersedes` (`ranking.rs:157`). The + in-memory engine has **no** hosted-pin discovery. +- `docs/design/configuration.md` said socket-patch never reads + `socket.yml`. This plan reverses that (section 3); the doc is updated in + the same PR. + +### 2.3 Hard-coded filtering inventory + +socket-patch (paths relative to `crates/`): + +| # | Location | What | Verdict | +|---|---|---|---| +| H1 | `socket-patch-cli/src/hosted_memory/roots.rs:56-67` `EXCLUDED_ROOT_SEGMENTS` | the in-memory engine never detects a project root under `node_modules .git .socket .yarn vendor test tests fixtures __fixtures__ testdata` | **Move** `test tests fixtures __fixtures__ testdata` to the overridable default `ignorePaths` (section 4.3), applied on disk too. Keep `node_modules .git .socket .yarn vendor` structural. | +| H2 | `socket-patch-core/src/crawlers/npm_crawler.rs:19-27` `SKIP_DIRS` (dist build coverage tmp temp `__pycache__` vendor) | npm workspace walk looking for nested `node_modules` | Stays: crawler heuristic, not selection policy | +| H3 | `socket-patch-cli/src/hosted_memory/select.rs:46,53-70` | cargo `target/` and `cargo vendor` output skipped | Stays: correctness | +| H4 | `socket-patch-cli/src/hosted_memory/roots.rs:40-53` | maven/nuget unsupported in memory | Stays: capability | +| H5 | `socket-patch-cli/src/hosted_memory/mod.rs:274` `ecosystem_allowed` | `options.ecosystems` | Stays: the in-memory `--ecosystems`; intersects with the file | +| H6 | `crawlers/python_crawler.rs:288`, dot-dir skips in `cargo_crawler.rs:373`, `go_crawler.rs:344`, `nuget_crawler.rs:236`, `ruby_crawler.rs:576` | discovery locations | Stays: crawler heuristics | +| H7 | `ruby_crawler.rs:823` BUNDLE_PATH containment | refuse config roots outside the project | Stays: **safety** | +| H8 | `scan/mod.rs:596-603`, `get.rs:1101-1107` tier filter | paid patches for free orgs | Stays: entitlement | +| H9 | `scan/mod.rs:723-762` agent partition | vendored / not-installed skips | Stays: ownership safety | +| H10 | `scan/hosted.rs:1256-1296` non-granted references | not_found, forbidden, pending_build, build_failed, withdrawn | Stays: server truth | +| H11 | `hosted.rs:1352-1402`, `hosted.rs:1448`, core rewriter refusals, vendor revert allowlists, `vlt_lock_text.rs:311` | write safety, format gates, ledger-poisoning guards | Stays: **safety** | + +No package-name, uuid or repo denylists exist anywhere in socket-patch. + +depscan (`feat/socket-patch-cli-autopatch`, PR #26860; `workspaces/app/src/autopatch-pr/cli/` unless noted): + +| # | Location | What | Verdict | +|---|---|---|---| +| D1 | `run-job.ts:94-102,293`; `next-app/.../socket-patch-cli/enqueue.ts:104` | per-org `socketPatchCliAutopatch` flag | Server (kill switch) | +| D2 | `provider/create-patch-provider.ts:189-279` | `enablePatchesAccess`, paid entitlement | Server (entitlement) | +| D3 | `lib/src/socket-patch/autopatch-job.ts:152-169` | per-job admin `config.ecosystems` allowlist, `batchSize` (lookup batch, not a patch cap) | Server; ecosystems **intersect** with `patches.ecosystems` | +| D4 | `repo-files.ts:290-513` | tree/path/size/depth caps, unsafe path drops | Server (safety) | +| D5 | `pull-request-rules.ts:473-491` | fork/default/protected heads are check-only | Server (safety) | +| D6 | `next-app/src/lib/admin/autopatch/socket-patch-cli-branches.ts:10-31` | branch-name guards | Server | +| D7 | legacy `patch-pr-worker.ts:65-109`, `github-patch-pr.ts:262-283,1598-1740`, `github-patch-pr-hosted.ts:196-486`, `compute-full-patch-set.ts:41-273` | already-applied, not-in-SBOM, unpublished, deprecated, vlt gates | Server (correctness); not policy | + +Nothing in depscan filters by repo path, package or severity, and nothing +caps patches per PR. The only repo-path policy in the whole system is H1, +and it lives in the engine. It is the one hard-coded filter that moves. + +### 2.4 Prior art (vocabulary borrowed, complexity not) + +| Tool | Limit | Counts | Order when capped | +|---|---|---|---| +| Dependabot | `open-pull-requests-limit` (5; security updates exempt) | open PRs | undocumented; shuffled | +| Renovate | `prConcurrentLimit`, `prHourlyLimit` (security fixes bypass) | open PRs / new per hour | vulnerability, `prPriority`, update type, title | +| Snyk | 5 open upgrade PRs; backlog "one PR a day, top vulnerability" | open PRs / per day | priority score | +| GitLab auto-remediation | 10 open MRs, "three vulnerabilities at a time, highest severity first", `high` threshold | open MRs + per run | severity | +| OSV-Scanner | `--apply-top=N`, `--min-severity` | per run | fixed | + +Borrowed: gitignore paths (`projectIgnorePaths`), `include`/`ignore` +pairs, `enabled`, a severity floor spelled as a minimum (`--min-severity`, +Snyk/GitLab/OSV), a per-run new-item cap (GitLab/OSV/Snyk backlog), a +fixed total order (not Dependabot's shuffle), deny-wins. + +## 3. Trust boundary (decision) + +The rule in `CLI_CONTRACT.md` ("Repo-level files never carry endpoints, +credentials, or interlock-disablers") stays and gains its positive half: + +> A repository file may **narrow or pace** what `scan` patches. It may +> never widen it, name an endpoint or credential, pick a mode, or turn off +> a safety check. + +Every `patches:` key only removes candidates (`enabled`, `includePaths`, +`ignorePaths`, `ecosystems`, `packages`, `ignorePackages`, `minSeverity`) +or delays them (`maxNewPatches`). No key can add a package, bypass the tier +filter, the agent partition, reference grants, containment checks or any +refusal in H7-H11. The parser has no fields for URLs, tokens, org, mode, +download mode or any `--no-*` safety switch; such keys are unknown keys and +fail validation (4.4). + +Failure direction follows from that: because the file only narrows, an +unreadable or invalid policy must not mean "no policy". It fails closed. + +## 4. `socket.yml` grammar (work item A) + +### 4.1 Keys + +```yaml +version: 2 # required for socket-patch to honor `patches` +projectIgnorePaths: # existing scanner key; socket-patch honors it too + - "crates/*/tests/fixtures/**" +patches: # new; every key optional + enabled: true # bool. Default true. false = report only. + includePaths: ["/services/payments/"] # gitignore list. Absent = every project. + ignorePaths: ["/legacy/"] # gitignore list, added after the defaults. Default []. + ecosystems: [npm, pypi] # allowlist of --ecosystems names. Absent = all. + packages: ["lodash"] # allowlist of --package specs. Absent = all. + ignorePackages: ["pkg:npm/left-pad"] # denylist of --package specs. Default []. + minSeverity: high # critical|high|medium|moderate|low. Absent = no floor. + maxNewPatches: 5 # integer >= 0. Absent = unlimited. 0 = upgrades only. +``` + +- camelCase, like every existing socket.yml key. +- Ecosystem names are `Ecosystem::cli_name()`: `npm pypi cargo gem golang + maven composer nuget deno`, case-insensitive. +- Package specs use exactly the `--package` grammar and matcher + (`package_spec_matches`): a name (full or last segment, + case-insensitive) or a purl with or without a version; qualifiers + ignored. +- `moderate` is an alias of `medium`, as in `severity_order`. +- Deny wins: `ignorePackages` beats `packages`, ignore paths beat + `includePaths`. + +### 4.2 Precedence against flags and env + +| Setting | Rule | +|---|---| +| List filters (`includePaths`/`ignorePaths`/`projectIgnorePaths` vs PATH args; `ecosystems` vs `--ecosystems`; `packages`/`ignorePackages` vs `--package`) | **intersect**: flags narrow further, never widen | +| `minSeverity` | `--min-severity ` > `SOCKET_MIN_SEVERITY` > file > no floor | +| `maxNewPatches` | `--max-new-patches ` > `SOCKET_MAX_NEW_PATCHES` > file > unlimited | +| whole file | `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` skips the file (built-in default path ignores still apply) | + +Scalars follow the contract's CLI > env > default order, with the file as +the layer above the default. The person running the CLI is trusted; the +file is the repo's default. (depscan adds its own server ceiling, section +7.) Every new flag has an env binding, as the contract requires. + +### 4.3 Paths + +- **Subject.** Path rules decide which *project roots* are patched: the + directory holding the lockfile/manifest, relative to the repo root, with + `/` separators, tested as a directory. The rule is the same in every + mode, including agent mode (its project is `--cwd`). +- **Semantics.** gitignore, identical to the backend's `ignore` package: + Rust `ignore::gitignore::GitignoreBuilder` with `case_insensitive(true)`, + anchored at the repo root, `matched_path_or_any_parents` so a directory + pattern covers everything under it. +- **Repo-root project.** gitignore cannot match the empty path, so in + `patches.includePaths` / `patches.ignorePaths` the literal entry `/` + (and `!/`) means "the repository-root project". It has no meaning in + `projectIgnorePaths`, which stays scanner semantics. +- **Evaluation order** (last match wins): + 1. built-in defaults: `test/ tests/ fixtures/ __fixtures__/ testdata/` + 2. `projectIgnorePaths` + 3. `patches.ignorePaths` + + A user re-includes a default with a negation, e.g. + `ignorePaths: ["!/e2e/tests/"]`. Adding an unrelated ignore never + silently re-enables fixtures. +- **Admission.** A root is admitted iff it is not ignored by the list + above AND (`includePaths` is absent OR `includePaths` matches it). +- **Defaults apply to discovered roots only.** Built-in defaults (step 1) + prune roots the tool discovers (in-memory root detection; disk PATH-glob + expansion). A directory the user names explicitly (`--cwd`, a literal + PATH) is exempt from step 1 but not from steps 2-3 or `includePaths`. +- **Structural excludes** (`node_modules .git .socket .yarn vendor`) stay + hard-coded and cannot be negated. +- **Granularity.** A workspace member that shares the root lockfile is part + of the root project; exclude it with `ignorePackages`, not paths. + Documented. +- A root excluded by the policy is reported as filtered (4.6) with the + pattern that decided it and the list it came from. + +### 4.4 Validation (fail closed) + +socket-patch validates only what it reads: `version`, `projectIgnorePaths` +and `patches`. Other top-level keys are never inspected. + +| Situation | Behavior | +|---|---| +| No file | Defaults. | +| YAML syntax error, duplicate key, top level not a mapping, file over 64 KiB, symlink resolving outside the repo root | **Error** `socket_yml_invalid` | +| `patches` present and `version` is not `2` (integer 2 or the string `"2"`, matching ajv coercion), including a missing `version` | **Error** `socket_yml_invalid` ("patches requires version: 2") | +| No `patches` and `version` is not 2 | File ignored (a v1 or future file is not ours to judge); warning `socket_yml_unsupported_version` | +| Unknown key under `patches` | **Error**, with a did-you-mean hint when one key is within edit distance 2 | +| Wrong type (no coercion: `"false"` is not a bool; YAML 1.2 so `no` is a string), unknown ecosystem or severity, `maxNewPatches` negative or non-integer, invalid glob or package spec, `projectIgnorePaths` not a list of strings | **Error** naming the key path (`patches.minSeverity`) and the file | +| Top-level key equal to `patch`/`patches` ignoring case but not exactly `patches` (`Patches:`, `PATCH:`, `patch:`) | **Error**: a misspelled block must not silently mean "no policy" | +| Both `socket.yml` and `socket.yaml` at the root | Parse both. If the parts socket-patch reads (`projectIgnorePaths`, `patches`) are equal, use them; otherwise **error** `socket_yml_ambiguous` naming both. The existing consumers disagree on which file wins, so we refuse to pick. | + +**Error behavior:** before any write, `scan` exits **1** with +`errorCode: socket_yml_invalid` (or `socket_yml_ambiguous`), a human +message naming file, key path and remedy (fix the file, or +`--no-socket-yml`), `--json` stdout still a valid envelope. Exit 1, not 2: +it is a bad input file, like an invalid manifest, not a usage error. + +**Forward compatibility.** A strict parser means an older pinned CLI fails +on a key a newer CLI understands. That is deliberate (the alternative is a +silently wider rollout); the error text says "unknown key … (a newer +socket-patch may support it; upgrade or remove it)". The contract documents +that keys are only ever added in minor releases and never change meaning. + +### 4.5 Lookup + +1. Repo root := the nearest ancestor of `--cwd` (inclusive) containing + `.git` (a directory, or a file for worktrees and submodules). With no + `.git` ancestor, repo root := `--cwd` (never the home directory or + filesystem root; socket-cli's unbounded walk could pick up an untrusted + `/tmp/socket.yml`). +2. Read `/socket.yml` and `/socket.yaml` only. + Nested `socket.yml` files are not read. One file per repo, as in the + GitHub App. +3. The in-memory engine's repo root is the tree root it was given. +4. `--global` / `--global-prefix` scans have no repo and ignore the file. + +### 4.6 Commands + +| Command | Policy | +|---|---| +| `scan` (hosted, vendored, agent; wet and `--dry-run`), `hosted-bundle`, the napi engine | honor filters and limit | +| `get` | explicit intent: ignores filters and limit; warns `policy_bypassed` when the target would have been filtered | +| `apply`, `list`, `vex`, `rollback`, `remove`, `repair`, `vendor` (eject/revert) | ignore it: they report, attest or undo existing state | + +**Narrowing never removes.** The policy runs after the prune universe is +captured (`mod.rs:1480`), so `--prune` still judges the full crawl. A +package that already has a recorded patch but is now excluded by paths, +ecosystems, packages or `enabled: false` is **retained**: not passed to the +hosted rewriters, vendor engine or agent apply; not upgraded; not taken +over; left byte-identical. It is reported under `policy.retained[]` with +`upgradeAvailable`. Removing a patch is only ever `rollback`/`remove`, or +the dependency leaving the lockfile. + +`minSeverity` filters **candidates** before per-package ranking (so a +lower-ranked patch above the floor can still win), using the patch's real +severity (`severity_order` on `BatchPatchInfo.severity`, or +`max_severity_order` over `vulnerabilities`), never `RankKey.severity`. +With a floor set, a patch with unknown severity is filtered (fail closed). +A recorded patch below the floor stays in place; it is replaced only when a +candidate above the floor supersedes it under the existing +`batch_supersedes` rule, which is an ordinary upgrade. + +`enabled: false`: discovery and the table still run; nothing is written; +every candidate is reported filtered with `policy_disabled`; warning +`patches_disabled`; exit 0. Upgrades are frozen too. + +### 4.7 JSON (`policy` block, owned by A) + +Additive top-level key on every `scan --json` result (MINOR), always +present: + +```json +"policy": { + "source": "file", // "none" | "file" | "bypassed" + "path": "socket.yml", // repo-relative; null unless source=file + "sha256": "…", // of the file bytes; null unless source=file + "enabled": true, + "minSeverity": {"value": "high", "source": "file"}, // value null = no floor; source flag|env|file|default + "counts": {"filtered": 3, "retained": 1}, + "filtered": [ + {"purl": "pkg:npm/qs@6.5.2", "uuid": "…", "project": "services/legacy", + "reason": "policy_path_excluded", "detail": "/legacy/ (patches.ignorePaths)"} + ], + "retained": [ + {"purl": "pkg:npm/lodash@4.17.20", "project": ".", "recordedUuid": "…", + "reason": "policy_package_ignored", "upgradeAvailable": true} + ] +} +``` + +- `uuid` is null when the package was filtered before any patch was looked + up (path, ecosystem, package reasons). +- Reason codes (stable): `policy_disabled`, `policy_path_excluded`, + `policy_path_not_included`, `policy_ecosystem`, + `policy_package_not_listed`, `policy_package_ignored`, `policy_severity` + (detail `unknown < high` or `medium < high`). +- A root filtered as a whole is one entry with `purl: null`. +- Human output: one line, e.g. + `Policy (socket.yml): 3 skipped by filters, 1 patched package held.` + and `--verbose` lists them. + +## 5. Per-run limit (work item B) + +### 5.1 Classification + +After filtering and per-package selection, each selected `(project root, +purl, uuid)` row is classified against that project's recorded view +(`merge_ledger_records_for_updates`: manifest > hosted lockfile pins > +vendor ledger), with `detect_updates`' qualifier-twin handling: + +| Class | Rule | Counts toward the cap | +|---|---|---| +| ALREADY | recorded uuid == selected uuid, or recorded uuid kept because the selection does not supersede it (`batch_supersedes`) | no; hosted re-confirms it idempotently as today | +| UPGRADE | recorded uuid differs and the selection supersedes it (existing `detect_updates` rule, including "recorded patch no longer offered") | no | +| NEW | no patch recorded for this base purl **in this project root** | **yes** | + +- NEW is per project root. Widening `includePaths` from a pilot directory + to more services makes piloted packages NEW in the added roots, and they + go through the cap again. A patch already in another project is not a + free pass. +- UPGRADEs are exempt (decision): rollout risk is about whether a package + runs patched code at all, and an upgrade fixes more in a package that is + already patched. Capping upgrades would leave known-superseded patches in + place. To freeze everything, use `enabled: false`; `maxNewPatches: 0` + freezes new packages only. + +### 5.2 Budget and ordering + +- **Unit:** a distinct **base purl** (ecosystem + name + version, + qualifiers stripped) among NEW rows, run-wide: across every project root + of one invocation (disk multi-directory human runs, every root of the + in-memory engine). Admitting a base purl admits all of its NEW rows in + every root. One package patched in ten roots costs 1. +- **Order** (ascending; a total order with no time-dependent keys): + 1. in-flight first (in-memory option `inFlightPatches` only, 7.2; + absent on the CLI) + 2. real severity of the selected patch (`severity_order`: critical, + high, medium, low, unknown) + 3. advisory count, descending (merged patches first within a severity) + 4. ecosystem `cli_name`, ascending + 5. canonical base purl, ascending bytewise (one shared core + normalization function, used by disk and memory) + 6. uuid, ascending + + A base purl present in several roots uses the minimum key of its rows. + `publishedAt` is deliberately **not** a key: the batch endpoint omits it, + so using it would reorder the top N between runs and between the disk + and memory engines. Per-package ranking (which patch a package gets) + still uses `publishedAt` as today; this order only decides which + packages go first. +- **Eligibility before budget.** A row consumes budget only if it can land + this run: it passed the tier filter, the agent partition (vendored / + not installed), the vendored preflight and, in hosted mode, its reference + grant came back `granted`. Rows that cannot land (withdrawn, + build_failed, pending_build, not_found, forbidden, refused) keep their + existing skip reasons and do not hold a slot, so a permanently broken + patch can never stall the rollout. Implementations may fetch references + for every NEW candidate, or in rank-ordered batches until the budget is + full; the resulting plan must be identical. +- **Write failures** after admission consume budget (the run stays bounded; + no backfill within a run). They are reported as failures, as today. +- **`maxNewPatches: 0`** admits no NEW rows; ALREADY and UPGRADE proceed. +- Everything NEW beyond the budget is **deferred**: not written, not + downloaded, not vendored, reported with its rank. + +### 5.3 Convergence and determinism + +- Same inputs, same plan, same bytes. The limit is stateless: run k lands + the top N; on run k+1 they are ALREADY and the next N land. M waiting + patches take ceil(M/N) committed runs. +- A newly published or re-scored higher-severity patch moves ahead of the + queue. That is intended ("most critical first") and is visible because + every deferred entry carries its rank and severity. +- Low-severity patches can wait indefinitely while higher ones keep + arriving. Documented; it is the point of severity ordering. +- **CI that does not commit** the scan's result never advances recorded + state, so a cap there means "only the top N, every run". Documented in + the recipes: commit the lockfile changes (or use a PR bot), or do not set + a cap in non-committing jobs. +- `pending_build` references are transient: a row can be ineligible one + run and eligible the next. The plan is still a function of the inputs. + +### 5.4 Modes + +| Mode | Recorded state | NEW/ALREADY/UPGRADE source | Deferred rows | +|---|---|---|---| +| hosted (disk) | lockfile hosted pins (`HostedPin`) | recorded view | never granted, never rewritten; mirrored into `redirect.skipped[]` with reason `rollout_deferred` | +| vendored | `.socket/vendor/state.json` | ALREADY = `already_vendored`, UPGRADE = `would_revendor` | never downloaded or vendored | +| agent | `.socket/manifest.json` | ALREADY = `skipped`, UPGRADE = `updated` | never downloaded; not in `apply.patches[]` | +| in-memory (napi, hosted-bundle) | hosted pins discovered from the in-memory lockfiles (**new**, B) | same | in `ProjectResult.deferred[]` and `skipped[]` with `rollout_deferred` | + +`--dry-run` makes exactly the same decisions and reports them the same way. +A takeover of an existing vendored or hosted entry counts as recorded, not +NEW. + +### 5.5 JSON (`rollout` block, owned by B) + +Additive top-level key on every `scan --json` result (MINOR), always +present: + +```json +"rollout": { + "maxNewPatches": {"value": 5, "source": "file"}, // value null = unlimited; source flag|env|file|default|cap + "counts": {"new": 5, "deferred": 9, "upgrade": 1, "already": 12}, + "deferred": [ + {"purl": "pkg:npm/minimist@1.2.5", "uuid": "…", "severity": "critical", + "advisoryCount": 1, "projects": ["services/api", "services/web"], "rank": 6} + ] +} +``` + +- `counts.new` is the number of NEW base purls admitted this run + (landed, or would land under `--dry-run`); `deferred` lists the rest in + rank order; `rank` is 1-based across all NEW candidates. +- Human output (hosted/vendored/agent summary, then the Next-steps + renderer): + + ``` + Rollout: 5 of 14 new patches applied (maxNewPatches=5 from socket.yml); 1 upgrade, 12 already applied. + Next steps: + 9 new patches deferred; commit these changes and run scan again to apply the next 5. + Next up: minimist@1.2.5 (critical), qs@6.5.2 (high), … + ``` +- Exit code unchanged (0) when patches are deferred or filtered. +- `jq` recipe for CI: `jq '.rollout.counts.deferred'`. + +## 6. Rollout recipes + +```yaml +# R1 Canary: one new patch per run +version: 2 +patches: + maxNewPatches: 1 +``` + +```yaml +# R2 Critical first: critical only, then widen by editing one line +version: 2 +patches: + minSeverity: critical # later: high, then remove + maxNewPatches: 5 +``` + +```yaml +# R3 One directory first (monorepo) +version: 2 +patches: + includePaths: + - "/services/payments/" + # add "/services/checkout/" next sprint + maxNewPatches: 5 +``` + +```yaml +# R4 One ecosystem, hold one package +version: 2 +patches: + ecosystems: [npm] + ignorePackages: ["pkg:npm/left-pad"] +``` + +```yaml +# R5 Weekly drip with the depscan autopatch PR +version: 2 +patches: + maxNewPatches: 5 # the PR keeps the same 5 until merged, then the next 5 +``` + +```yaml +# R6 Pause +version: 2 +patches: + enabled: false # report only; existing patches stay in place +# or keep upgrades flowing but add nothing new: +# maxNewPatches: 0 +``` + +One-off overrides from the command line: `socket-patch scan +--max-new-patches none` (drain the queue this run), `--min-severity none`, +`--no-socket-yml` (ignore the file entirely). + +## 7. depscan autopatch service + +### 7.1 Behavior with the new engine + +- `repo` jobs rebuild one commit from the base SHA each run. With + `maxNewPatches: 5`, "recorded" means pinned on the **base** branch, so + every rebuild proposes the same top 5 until the PR merges, then the next + 5. No churn, no new PR per batch. +- `pull_request` jobs honor the filters but pass `maxNewPatches: "none"`: + deferring there would leave the check permanently showing work. +- socket.yml is read from the same commit as the tree (base SHA for `repo` + jobs, head SHA for `pull_request` jobs), through the engine: the file is + one of the paths the engine asks for, so there is no second parser. +- Effective limit = min(repo value or override, server cap). The server + can tighten, never loosen. Org-level kill switches, entitlement and + safety (D1-D6) always win. + +### 7.2 Engine API changes (napi `HostedScanOptions` / result, and the `hosted-bundle` harness) + +| Owner | Change | +|---|---| +| A | `selectHostedScanPaths` returns root `socket.yml` / `socket.yaml` when present in the tree listing (one phase: the file is small and root-only; roots the policy excludes are simply not processed) | +| A | options `noSocketYml?: boolean`, `minSeverity?: "critical"\|"high"\|"medium"\|"low"\|"none"` | +| A | result: session-level `policy` block (4.7) and `policyError?: {code, detail}`; on error no project is processed and no files change; `skipped[].reason` gains the `policy_*` codes | +| B | options `maxNewPatches?: number \| "none"`, `maxNewPatchesCap?: number`, `inFlightPatches?: string[]` (uuids already in the open PR; ranked first so a reviewed patch is not displaced by a newly published one mid-review) | +| B | result: session-level `rollout` block (5.5); `ProjectResult.deferred[]`; `skipped[]` rows with `rollout_deferred` | +| B | hosted-pin discovery over the in-memory lockfiles (the memory twin of `HostedPin::all(discover_wiring(..))`), so NEW/ALREADY/UPGRADE work in memory. A finite cap must never ship in the engine without it: every merged pin would look NEW and the rollout would stall at N. | +| B | restructure the per-root loop at `hosted_memory/mod.rs:537` into collect all roots → plan once → apply, so the budget is run-wide | + +`hosted-bundle` rejects unknown fields, so each owner adds its fields there +too. + +### 7.3 depscan follow-up (after A and B merge; separate PR in depscan) + +1. Bump the socket-patch submodule and rebuild the addon. +2. Pass `inFlightPatches` (uuids in the open patch-all PR) and, for + `pull_request` jobs, `maxNewPatches: "none"`. +3. New job outcome `policy_invalid` (from `policyError`): leave the + existing PR untouched, surface the error on the admin page and in the + job's check-run text. +4. Render a "Deferred (next batch)" table and severity/rank columns in the + PR body; add `patchesDeferred` to stats. +5. Optional server cap per org (future org setting), passed as + `maxNewPatchesCap`; intersect the admin `config.ecosystems` (D3) with + the file by passing it as `ecosystems` as today. +6. Do **not** add `patches` to the ajv schema in + `socket-yaml-schema.ts` with strict types: a typo would reject the whole + file and turn PR checks neutral. If documentation value is wanted, add + it as a permissive `{type: object}`. +7. Docs repo: add a `patches` section to the socket.yml page, and fix the + two stale statements found in research (which file wins when both + exist; v1 files are rejected by the GitHub App). + +A closed/rejected rolling PR re-proposes the same patches next run; +document `ignorePackages` as the way to decline one. + +## 8. Decisions log + +| # | Decision | Why | +|---|---|---| +| 1 | Top-level `patches:` in socket.yml v2; no `version: 3` | breaks no parser (P1/P2 strip, P3 ignores); P3 rejects any version but 2 | +| 2 | socket-patch reads socket.yml (reverses configuration.md) | owner request; policy that only narrows fits the trust boundary | +| 3 | Keys `enabled includePaths ignorePaths ecosystems packages ignorePackages minSeverity maxNewPatches` | include/ignore pairs mirror existing keys; `packages` allowlist covers single-package pilots; `maxNewPatches` says it counts new patches only | +| 4 | gitignore semantics via the `ignore` crate, case-insensitive, anchored at repo root | identical to `projectIgnorePaths` in the backend | +| 5 | socket-patch also honors `projectIgnorePaths` | users expect one ignore list; every other consumer already honors it | +| 6 | Defaults `test/ tests/ fixtures/ __fixtures__/ testdata/` evaluated first, overridden by `!`; discovered roots only | moves H1; replace-on-set would re-enable fixtures when someone adds one unrelated pattern | +| 7 | Strict validation, fail closed, exit 1 `socket_yml_invalid` | a broken narrowing rule must not widen the rollout | +| 8 | Both files: error only if the parts we read differ | existing consumers disagree on precedence; repos that already have both keep working | +| 9 | Repo root = nearest `.git` ancestor, else `--cwd`; root files only | matches the GitHub App; memory engine can mirror it; never reads outside the checkout | +| 10 | Flags intersect lists; scalars CLI > env > file > default; `--no-socket-yml` with env | contract precedence and "every flag has an env var" | +| 11 | `maxNewPatches: 0` = upgrades only; absent / `none` = unlimited | literal meaning; avoids the Dependabot/Renovate 0 disagreement | +| 12 | Unknown severity is filtered when a floor is set | fail closed | +| 13 | NEW per (project root, base purl); budget per base purl run-wide | a widened pilot re-enters the cap; one package in many roots costs 1 | +| 14 | Upgrades exempt from the cap | rollout risk is per package; keeps patched packages current | +| 15 | Order: severity, advisory count, ecosystem, base purl, uuid; no `publishedAt` | total and time-independent; batch lacks the date | +| 16 | Budget after eligibility (grants, partition, preflight) | a withdrawn/broken patch never holds a slot | +| 17 | Filtered packages with recorded patches are retained, never removed or upgraded | narrowing freezes, never removes | +| 18 | `get` bypasses the policy with a warning | explicit intent | +| 19 | Separate `policy` (A) and `rollout` (B) JSON blocks | clean ownership seam; both additive | +| 20 | Everything ships in 5.0 | honoring `projectIgnorePaths`, disk default ignores and fail-closed file errors change scan's default behavior (MAJOR) | + +## 9. Work items + +Both items branch from `release/v5-prerelease` (suggested branches +`v5/rollout-policy` for A, `v5/rollout-limit` for B). **Merge order: A, +then B.** +B rebases onto A and owns the final integration (section 9.3). Neither +item depends on the other's types: the only exchanged values are plain +`Option` / `Option` and the pipeline order below. + +### 9.0 Shared contract (frozen by this plan) + +Scan pipeline, in order (disk and memory): + +1. load policy (A) — fail closed before any write +2. crawl; capture the prune universe (unchanged) +3. root filter, ecosystem/package filter, retained set (A) +4. batch API (unchanged) +5. candidate severity filter (A) +6. per-package ranking (unchanged `ranking`) +7. classify NEW/ALREADY/UPGRADE, eligibility, budget, deferral (B) +8. writers (unchanged; receive only admitted rows) + +```rust +// crates/socket-patch-core/src/policy/mod.rs — OWNER A +pub struct SelectionPolicy { /* private fields */ } +pub enum PolicySource { None, File { path: String, sha256: String }, Bypassed } +pub enum FilterReason { + Disabled, PathExcluded { pattern: String, list: &'static str }, PathNotIncluded, + Ecosystem, PackageNotListed, PackageIgnored { spec: String }, + Severity { found: Option, floor: String }, +} +impl FilterReason { pub fn code(&self) -> &'static str; pub fn detail(&self) -> String; } +pub enum PolicyError { Invalid { file: String, key: String, message: String }, Ambiguous { files: [String; 2] } } +impl PolicyError { pub fn code(&self) -> &'static str; } // socket_yml_invalid | socket_yml_ambiguous +pub trait PolicyFs { fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result>>; } +pub struct PolicyOverrides { pub bypass: bool, pub min_severity: Option> } // Some(None) = "none" +impl SelectionPolicy { + pub fn unrestricted() -> Self; // defaults (built-in path ignores only) + pub fn load(fs: &dyn PolicyFs, o: &PolicyOverrides) -> Result; + pub fn source(&self) -> &PolicySource; + pub fn enabled(&self) -> bool; + pub fn admits_root(&self, rel_dir: &str, explicit: bool) -> Result<(), FilterReason>; + pub fn admits_purl(&self, purl: &str) -> Result<(), FilterReason>; // ecosystem + packages + pub fn admits_severity(&self, severity_order: u8) -> Result<(), FilterReason>; + pub fn max_new_patches(&self) -> Option; // the FILE value only; B resolves precedence +} + +// crates/socket-patch-core/src/rollout.rs — OWNER B +pub enum Recorded { None, Same, Kept { uuid: String }, Superseded { old_uuid: String } } +pub struct Candidate { + pub project: String, pub purl: String, pub base_purl: String, pub uuid: String, + pub ecosystem: &'static str, pub severity_order: u8, pub advisory_count: usize, + pub recorded: Recorded, pub eligible: bool, pub in_flight: bool, +} +pub enum MaxNewSource { Flag, Env, File, Default, Cap } +pub struct MaxNew { pub value: Option, pub source: MaxNewSource } +pub fn resolve_max_new(flag: Option>, env: Option>, + file: Option, cap: Option) -> MaxNew; +pub fn canonical_base_purl(purl: &str) -> String; +pub fn rollout_cmp(a: &Candidate, b: &Candidate) -> std::cmp::Ordering; +pub struct RolloutPlan { pub admitted: Vec, pub deferred: Vec<(Candidate, u32)>, pub counts: RolloutCounts } +pub fn plan_rollout(candidates: Vec, max_new: &MaxNew) -> RolloutPlan; // pure +``` + +Rules both items follow: +- Severity input is always the patch's real severity (`severity_order` / + `max_severity_order`), never `RankKey.severity`. +- Skip-reason strings are the stable codes in 4.7 and 5.5. +- JSON: A owns the top-level `policy` block; B owns the top-level + `rollout` block. Neither edits the other's. +- CLI args: A adds a `#[command(flatten)]` `SocketYmlArgs` (`--no-socket-yml`, + `--min-severity`) in `scan/socket_yml_args.rs`; B adds a flattened + `RolloutArgs` (`--max-new-patches`) in `scan/rollout_args.rs`. Both + derive `Default`; each adds its field to the ~18 `ScanArgs` struct + literals. The resulting adjacent-line conflicts are resolved by B on + rebase. + +### 9.1 Work item A — socket.yml loading and filtering + +Scope: +- `crates/socket-patch-core/src/policy/{mod.rs, socket_yml.rs, paths.rs}`; + `pub mod policy;` in `crates/socket-patch-core/src/lib.rs`. +- Dependencies, exact-pinned in `Cargo.toml`: a maintained YAML 1.2 serde + crate that reports duplicate keys (e.g. `serde_norway`; verify + duplicate-key rejection with a test, reject it otherwise), and `ignore` + (gitignore matcher). No other new deps. +- Loader: lookup (4.5), size and symlink confinement, both-files rule, + strict validation with key paths and did-you-mean (4.4), `PolicyFs` for + disk and for the in-memory engine. +- Filters, wired at the pipeline points in 9.0: + - disk: root filter in `project_dirs` / `run_project_dirs` + (`scan/mod.rs:1268-1320`) and the agent project; `admits_purl` next to + `--package` (`scan/mod.rs:1490-1511`); severity filter on batch + candidates after `scan/mod.rs:1801` and on by-package candidates before + `select_patches` in the human arm; retained set computed from the + recorded view and excluded from writers. + - memory: root filter in `hosted_memory/roots.rs` root detection; + `admits_purl` at `hosted_memory/mod.rs:428-432`; severity filter before + `select_top_ranked`. +- Move `test tests fixtures __fixtures__ testdata` out of + `EXCLUDED_ROOT_SEGMENTS` (`hosted_memory/roots.rs:56-67`) into the + built-in default ignores, and apply them to disk PATH-glob expansion. +- `enabled: false` report-only path; `get`'s `policy_bypassed` warning; + `--global` ignores the file. +- Flags: `--no-socket-yml`/`SOCKET_NO_SOCKET_YML`, + `--min-severity`/`SOCKET_MIN_SEVERITY` (`SocketYmlArgs`). +- napi + hosted-bundle: `selectHostedScanPaths` includes root + `socket.yml`/`socket.yaml`; options `noSocketYml`, `minSeverity`; result + `policy` and `policyError`; `npm/index.d.ts` types. +- JSON `policy` block (4.7), human policy line, error envelopes for + `socket_yml_invalid` / `socket_yml_ambiguous`, warnings + `socket_yml_unsupported_version`, `patches_disabled`, `policy_bypassed`. + +Tests: +- Unit (core, table-driven): every row of 4.4; gitignore cases (anchoring, + bare names, trailing `/`, `!` and the excluded-parent rule, case + insensitivity, the `/` root form, defaults + negation); package specs; + severity floor incl. unknown and `moderate`; both-files equal/different; + lookup with `.git` dir, `.git` file, no git. +- Parser contract: `tests/cli_parse_scan.rs` rows for the two flags and + env vars. +- E2E (wiremock, `tests/in_process_scan.rs` style): hosted, vendored, + agent and `--dry-run` with a socket.yml that filters by path, ecosystem, + package and severity; invalid file → exit 1, no bytes changed; + `--no-socket-yml` bypass; narrowing after a patch is applied leaves the + pinned package byte-identical in hosted, vendored and agent modes + (retained); `--prune` universe unchanged. +- Parity: `tests/hosted_memory_parity.rs` gains a socket.yml fixture; disk + and memory filter the same roots and packages. +- This repo's own `socket.yml` keeps working (its `projectIgnorePaths` + now also excludes the fixtures from patching). + +Docs (A): `CLI_CONTRACT.md` (new "socket.yml patch policy" section: +grammar, precedence, lookup, validation, commands; flag + env rows; error +codes; `policy` JSON block; the trust-boundary bullet gains the "narrow or +pace" sentence), README (scan section: "Roll out gradually" with recipes +R1-R4, R6), CHANGELOG `[Unreleased]` (Added: socket.yml patch policy; +Changed (BREAKING): scan honors `projectIgnorePaths`, default test/fixture +ignores on discovered roots, invalid socket.yml fails scan). + +### 9.2 Work item B — limit, ordering, reporting + +Scope: +- `crates/socket-patch-core/src/rollout.rs`; `pub mod rollout;` in + `crates/socket-patch-core/src/lib.rs`. +- Make `discover_selected` (`scan/mod.rs:553`) the single disk selection + point: route the human agent/vendored arm (`mod.rs:2386-2402`) through + it, and have it return `{selected, deferred}` so hosted + (`run_redirect_selected`, `hosted.rs:1196`), vendored and agent writers + receive only admitted rows. +- Classification from `merge_ledger_records_for_updates` / + `detect_updates` per project root; eligibility (tier, agent partition, + vendored preflight, hosted reference grants — grants fetched for NEW + candidates in rank order, identical result either way); `plan_rollout` + with one run-wide budget across `run_project_dirs`. +- In-memory engine: hosted-pin discovery over in-memory lockfiles; + collect → plan → apply restructure around `hosted_memory/mod.rs:537`; + options `maxNewPatches`, `maxNewPatchesCap`, `inFlightPatches`; result + `rollout`, `ProjectResult.deferred[]`, `rollout_deferred` skips; + `npm/index.d.ts`; hosted-bundle fields. +- Flag: `--max-new-patches `/`SOCKET_MAX_NEW_PATCHES` + (`RolloutArgs`); `resolve_max_new` precedence including the file value + from A (9.3). +- JSON `rollout` block (5.5), `redirect.skipped[]` mirror, human + "Rollout:" line and the Next-steps deferred line (hosted + `format_next_steps`, `hosted.rs:3457`, and the agent/vendored + summaries). + +Tests: +- Unit (core): `rollout_cmp` total order (property: sorting any + permutation gives the same result); `plan_rollout` caps only NEW, + counts base purls run-wide, one package across roots costs 1, 0 = no + NEW, `none` = unlimited, ineligible rows hold no slot, in-flight first; + `resolve_max_new` precedence table incl. cap; `canonical_base_purl` + twins. +- E2E (wiremock): hosted, vendored, agent, `--dry-run`: 9 candidates with + `--max-new-patches 3` apply the 3 most severe; rerun on the result + applies the next 3; a third run the last 3; a fourth run changes nothing + (convergence); upgrades land regardless of the cap; a withdrawn + top-ranked patch does not consume budget; JSON `rollout` and + `redirect.skipped[]` contents; exit 0. +- Parity: `hosted_memory_parity.rs` cap fixture — disk and memory admit + and defer the same rows; memory rerun with pins in the lockfiles lands + the next N (needs pin discovery). +- Parser contract rows for the flag and env var. + +Docs (B): `CLI_CONTRACT.md` (limit semantics: classification, unit, +order, eligibility, convergence, starvation and non-committing-CI notes; +flag + env rows; `rollout` block; `rollout_deferred`; `jq` recipe; the +"Which patch gets selected" section notes the separate cross-package +order), README (recipe R5, `--max-new-patches`), CHANGELOG `[Unreleased]` +Added. + +### 9.3 Integration (B, after rebasing on A) + +- Pass `policy.max_new_patches()` as the `file` layer of `resolve_max_new`. +- Resolve the `ScanArgs` struct-literal conflicts (both flattened fields + present). +- Combined e2e: a socket.yml with `includePaths`, `minSeverity: high` and + `maxNewPatches: 2` over a two-root fixture, disk and memory, three runs to + convergence; `--no-socket-yml` drops the file's cap but keeps a flag cap. +- If B is ready before A merges, B ships with the file layer passed as + `None` and a follow-up commit on its branch wires it once A lands. + +## 10. Open questions (decided by default, revisit with evidence) + +- A separate upgrade cap (`maxUpgrades`) if server-side republishes rotate + too many pins at once. Default: none. +- CVSS/EPSS/KEV or reachability as ordering keys once the patch API + exposes them; they would slot between severity and advisory count. +- A generated JSON Schema for the `patches` block, shared with depscan and + the docs, to keep validators from drifting. diff --git a/docs/design/v5-plan.md b/docs/design/v5-plan.md index 5d131f99..df5a7aa3 100644 --- a/docs/design/v5-plan.md +++ b/docs/design/v5-plan.md @@ -130,6 +130,13 @@ patch-UI review. exit 2 for all usage errors; scan/get JSON onto `json_envelope`. - Full item list: 22 findings from the UI review (sizes S/M/L, contract flags). +### WS9 — Staged patch rollout *(branches `v5/rollout-policy` (A), `v5/rollout-limit` (B))* +- Added 2026-09-28 at the owner's request. `socket.yml` `patches:` policy + (paths, ecosystems, packages, severity floor, enabled) read by `scan` + and the in-memory engine, plus `scan --max-new-patches` (severity-ordered + per-run cap on new patches). Full plan and the two work-item specs: + `docs/design/staged-rollout.md`. Merge order A then B. + ## Remaining small follow-ups - ci.yml `e2e_cargo`/`e2e_golang` rows select `--ignored` but have no ignored tests (vacuous legs) → give them `--include-ignored` or drop the rows. From d087a633920e81b4eb14239307e78376e1bf3fcc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:54:49 +0000 Subject: [PATCH 02/14] Revise rollout plan after adversarial review Three independent reviews (ambiguity, churn, trust boundary) found gaps that would have let the two implementations disagree or let a repo file widen or stall the rollout. The plan now: - matches paths against marker files with the backend's top-down gitignore rules, so projectIgnorePaths means the same everywhere - uses one data source for severity, supersession and ordering - spends the budget only on patches the planning pass proves can land, and admits nothing new when a lookup failed - uses the merged recorded view in every mode and engine - has depscan read the policy from the base commit for PR jobs - hardens file handling (regular files, aliases, size, encoding, trusted repo root) and reports what a policy hides Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/design/configuration.md | 11 +- docs/design/staged-rollout.md | 848 +++++++++++++++++++++------------- 2 files changed, 525 insertions(+), 334 deletions(-) diff --git a/docs/design/configuration.md b/docs/design/configuration.md index 94a79bb0..4c4a617c 100644 --- a/docs/design/configuration.md +++ b/docs/design/configuration.md @@ -94,14 +94,17 @@ The trust boundary is unchanged and gains its positive half: never widen it, name an endpoint or credential, choose a mode or download format, or disable a safety interlock. The parser has no fields for any of those; such keys are unknown keys and fail validation. -- Because the file only narrows, an invalid file fails closed (exit 1, - `socket_yml_invalid`, nothing written) instead of being treated as - absent. This is the opposite of the socket-cli `config.json` rule above +- Because the file only narrows, an unreadable file or an invalid + `patches` block fails closed (exit 1, `socket_yml_invalid`, nothing + written) instead of being treated as absent. (A repo with no `patches` + block and a malformed `projectIgnorePaths` gets a warning, so repos that + never opted in do not start failing.) This is the opposite of the socket-cli `config.json` rule above (corrupt → warn and ignore), and deliberately so: ignoring a broken user-level login file loses a convenience; ignoring a broken repo policy widens the rollout. - Lookup is bounded to the repository (nearest `.git` ancestor of - `--cwd`, else `--cwd`), root files only. + `--cwd` owned by the user, honoring `GIT_CEILING_DIRECTORIES`, else + `--cwd`), root files only, regular files only. - Flags and env vars still win over the file for scalars (CLI > env > file > default) and intersect with it for list filters; `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` ignores the file. diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index 88b28511..eb834983 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -44,7 +44,8 @@ per-directory `socket.yml` files. walks up from cwd and prefers `socket.yml`. The docs say `socket.yml` wins. Nobody merges multiple files; there are no per-directory files. - Glob semantics (backend): the `ignore` npm package, i.e. **gitignore - rules**, case-insensitive (`list-files.ts:476-507`). A leading `/` or a + rules**, case-insensitive, tested against each manifest **file** path + (`list-files.ts:476-507`). A leading `/` or a middle `/` anchors to the repo root, a bare name matches at any depth, a trailing `/` matches directories only, `!` negates, last match wins, a child of an excluded directory cannot be re-included. @@ -129,14 +130,17 @@ pairs, `enabled`, a severity floor spelled as a minimum (`--min-severity`, Snyk/GitLab/OSV), a per-run new-item cap (GitLab/OSV/Snyk backlog), a fixed total order (not Dependabot's shuffle), deny-wins. + ## 3. Trust boundary (decision) The rule in `CLI_CONTRACT.md` ("Repo-level files never carry endpoints, credentials, or interlock-disablers") stays and gains its positive half: > A repository file may **narrow or pace** what `scan` patches. It may -> never widen it, name an endpoint or credential, pick a mode, or turn off -> a safety check. +> never name an endpoint or credential, pick a mode or download format, +> turn off a safety check, or make `scan` patch anything it would not +> patch with no file present. The one exception is negating the built-in +> test/fixture path ignores (4.3), which are repo policy by nature. Every `patches:` key only removes candidates (`enabled`, `includePaths`, `ignorePaths`, `ecosystems`, `packages`, `ignorePackages`, `minSeverity`) @@ -148,6 +152,8 @@ fail validation (4.4). Failure direction follows from that: because the file only narrows, an unreadable or invalid policy must not mean "no policy". It fails closed. +And because a policy can hide security fixes, what it hides is always +reported (4.7, 7.3), never silent. ## 4. `socket.yml` grammar (work item A) @@ -160,22 +166,28 @@ projectIgnorePaths: # existing scanner key; socket-patch honors it to patches: # new; every key optional enabled: true # bool. Default true. false = report only. includePaths: ["/services/payments/"] # gitignore list. Absent = every project. - ignorePaths: ["/legacy/"] # gitignore list, added after the defaults. Default []. + ignorePaths: ["/legacy/"] # gitignore list, evaluated after the defaults. Default []. ecosystems: [npm, pypi] # allowlist of --ecosystems names. Absent = all. - packages: ["lodash"] # allowlist of --package specs. Absent = all. + packages: ["pkg:npm/lodash"] # allowlist of --package specs. Absent = all. ignorePackages: ["pkg:npm/left-pad"] # denylist of --package specs. Default []. minSeverity: high # critical|high|medium|moderate|low. Absent = no floor. - maxNewPatches: 5 # integer >= 0. Absent = unlimited. 0 = upgrades only. + maxNewPatches: 5 # integer 0..=4294967295. Absent = unlimited. 0 = upgrades only. ``` - camelCase, like every existing socket.yml key. -- Ecosystem names are `Ecosystem::cli_name()`: `npm pypi cargo gem golang - maven composer nuget deno`, case-insensitive. +- Ecosystem names are any `Ecosystem::cli_name()` (`npm pypi cargo gem + golang maven composer nuget deno`), case-insensitive, valid whatever the + build supports; an unsupported ecosystem simply matches nothing. - Package specs use exactly the `--package` grammar and matcher - (`package_spec_matches`): a name (full or last segment, - case-insensitive) or a purl with or without a version; qualifiers - ignored. -- `moderate` is an alias of `medium`, as in `severity_order`. + (`package_spec_matches`, moved from the cli crate to core by A): a name + (full or last segment, case-insensitive) or a purl with or without a + version; qualifiers ignored. A bare name matches across ecosystems and + by last segment (`core` matches `@babel/core`), so the docs recommend + purls in `packages`/`ignorePackages`. Invalid spec: empty, or `pkg:` + without a type and name. +- `moderate` is an alias of `medium` everywhere (file, flag, env, napi). +- An empty allowlist (`includePaths: []`, `ecosystems: []`, + `packages: []`) is an error ("use `enabled: false`"), never "all". - Deny wins: `ignorePackages` beats `packages`, ignore paths beat `includePaths`. @@ -183,91 +195,142 @@ patches: # new; every key optional | Setting | Rule | |---|---| -| List filters (`includePaths`/`ignorePaths`/`projectIgnorePaths` vs PATH args; `ecosystems` vs `--ecosystems`; `packages`/`ignorePackages` vs `--package`) | **intersect**: flags narrow further, never widen | -| `minSeverity` | `--min-severity ` > `SOCKET_MIN_SEVERITY` > file > no floor | +| List filters (paths vs PATH args; `ecosystems` vs `--ecosystems`; `packages`/`ignorePackages` vs `--package`) | **intersect**: flags narrow further, never widen | +| `minSeverity` | `--min-severity ` > `SOCKET_MIN_SEVERITY` > file > no floor | | `maxNewPatches` | `--max-new-patches ` > `SOCKET_MAX_NEW_PATCHES` > file > unlimited | -| whole file | `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` skips the file (built-in default path ignores still apply) | +| whole file | `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` (bool, the contract's spellings) skips the file; built-in default path ignores still apply | Scalars follow the contract's CLI > env > default order, with the file as the layer above the default. The person running the CLI is trusted; the -file is the repo's default. (depscan adds its own server ceiling, section -7.) Every new flag has an env binding, as the contract requires. +file is the repo's default. Every new flag has an env binding. An empty env +value is unset (repo-wide rule); a malformed flag or env value is a usage +error (exit 2). depscan adds its own server ceiling (7.1). ### 4.3 Paths -- **Subject.** Path rules decide which *project roots* are patched: the - directory holding the lockfile/manifest, relative to the repo root, with - `/` separators, tested as a directory. The rule is the same in every - mode, including agent mode (its project is `--cwd`). -- **Semantics.** gitignore, identical to the backend's `ignore` package: - Rust `ignore::gitignore::GitignoreBuilder` with `case_insensitive(true)`, - anchored at the repo root, `matched_path_or_any_parents` so a directory - pattern covers everything under it. -- **Repo-root project.** gitignore cannot match the empty path, so in - `patches.includePaths` / `patches.ignorePaths` the literal entry `/` - (and `!/`) means "the repository-root project". It has no meaning in - `projectIgnorePaths`, which stays scanner semantics. -- **Evaluation order** (last match wins): +- **Subject: marker files.** The backend tests `projectIgnorePaths` + against manifest file paths, so socket-patch does the same for every + path list. A project root's **markers** are the lockfile/manifest files + in its directory that the engine reads for it (disk: the root's + lockfiles per the formats registry, plus its manifest; memory: + `hosted_memory/roots.rs` marker files). Paths are repo-relative with `/` + separators, e.g. `services/api/package-lock.json`, `package-lock.json` + for the repo-root project. + - A root is **ignored** iff **every** marker is ignored. + - With `includePaths` set, a root is **included** iff **any** marker + matches `includePaths`. + - Admitted iff included and not ignored. + This makes `/package-lock.json`, `**/yarn.lock`, `examples/**` and + `crates/x/fixtures/**` mean what they mean to the scanner, and needs no + special form for the repo-root project (target only it with + `includePaths: ["/*", "!/*/"]`). +- **Semantics: npm `ignore` exactly.** gitignore rules, case-insensitive, + anchored at the repo root: a leading or middle `/` anchors, a bare name + matches at any depth, a trailing `/` matches directories only, `!` + negates, last match wins. Evaluation walks **top-down**: for + `a/b/c.lock`, test `a/`, then `a/b/`, then the file; the first ignored + ancestor decides and a negation cannot re-include anything under it + (`fixtures/` + `!/a/fixtures/keep/` leaves `keep` ignored, as in the + backend). Do not use `ignore::gitignore`'s `matched_path_or_any_parents` + as-is: it walks bottom-up and would re-include. Implement the walk over + `Gitignore::matched(path, is_dir)`. `includePaths` uses the same walk + with "matched" in place of "ignored". +- A golden fixture of (patterns, path, expected) generated from the npm + `ignore` package is checked into the tests; the Rust matcher must agree + on all of it. +- **Pattern hygiene.** Reject (4.4) patterns that contain a `..` segment, a + drive letter, NUL, or exceed 1024 bytes. Backslash is gitignore's escape + character, not a separator (documented). +- **Evaluation order** of the ignore lists (one combined list, last match + wins within a path, top-down across ancestors): 1. built-in defaults: `test/ tests/ fixtures/ __fixtures__/ testdata/` 2. `projectIgnorePaths` 3. `patches.ignorePaths` - A user re-includes a default with a negation, e.g. - `ignorePaths: ["!/e2e/tests/"]`. Adding an unrelated ignore never - silently re-enables fixtures. -- **Admission.** A root is admitted iff it is not ignored by the list - above AND (`includePaths` is absent OR `includePaths` matches it). -- **Defaults apply to discovered roots only.** Built-in defaults (step 1) - prune roots the tool discovers (in-memory root detection; disk PATH-glob - expansion). A directory the user names explicitly (`--cwd`, a literal - PATH) is exempt from step 1 but not from steps 2-3 or `includePaths`. + Re-include a default with a negation: `ignorePaths: ["!/e2e/tests/"]`. + Adding an unrelated ignore never re-enables fixtures. The defaults are + now case-insensitive (`Test/` too), unlike H1. The backend's own + scanner defaults (`coverage`, `bower_components`, …) are not mirrored: + those are not dependency roots socket-patch would find. +- **Defaults apply to discovered roots only.** Step 1 never applies to a + root the user named explicitly: + + | Mode / entry point | Explicit roots | Discovered roots | + |---|---|---| + | disk hosted/vendored | `--cwd` with no PATH; a literal (non-glob) PATH | PATH-glob matches (`run_project_dirs` carries the flag per directory) | + | disk agent | `--cwd` (its only project; agent PATHs are package globs, not roots) | none | + | in-memory | roots given in `projectRoots` | roots found by detection | + + Steps 2-3 and `includePaths` apply to every root. - **Structural excludes** (`node_modules .git .socket .yarn vendor`) stay hard-coded and cannot be negated. - **Granularity.** A workspace member that shares the root lockfile is part of the root project; exclude it with `ignorePackages`, not paths. - Documented. -- A root excluded by the policy is reported as filtered (4.6) with the - pattern that decided it and the list it came from. +- **Outside the repo.** Roots are canonicalized; a PATH that resolves + outside the repo root (4.5) is a usage error (exit 2). One policy per + invocation. ### 4.4 Validation (fail closed) -socket-patch validates only what it reads: `version`, `projectIgnorePaths` -and `patches`. Other top-level keys are never inspected. +socket-patch validates `version`, `projectIgnorePaths` and `patches`, and +checks top-level key names for case variants of `patches`. It does not +validate any other key. + +Checks run in this order: file access, encoding, YAML, top-level shape, +case-variant check, version gate, keys. | Situation | Behavior | |---|---| -| No file | Defaults. | -| YAML syntax error, duplicate key, top level not a mapping, file over 64 KiB, symlink resolving outside the repo root | **Error** `socket_yml_invalid` | -| `patches` present and `version` is not `2` (integer 2 or the string `"2"`, matching ajv coercion), including a missing `version` | **Error** `socket_yml_invalid` ("patches requires version: 2") | -| No `patches` and `version` is not 2 | File ignored (a v1 or future file is not ours to judge); warning `socket_yml_unsupported_version` | -| Unknown key under `patches` | **Error**, with a did-you-mean hint when one key is within edit distance 2 | -| Wrong type (no coercion: `"false"` is not a bool; YAML 1.2 so `no` is a string), unknown ecosystem or severity, `maxNewPatches` negative or non-integer, invalid glob or package spec, `projectIgnorePaths` not a list of strings | **Error** naming the key path (`patches.minSeverity`) and the file | -| Top-level key equal to `patch`/`patches` ignoring case but not exactly `patches` (`Patches:`, `PATCH:`, `patch:`) | **Error**: a misspelled block must not silently mean "no policy" | -| Both `socket.yml` and `socket.yaml` at the root | Parse both. If the parts socket-patch reads (`projectIgnorePaths`, `patches`) are equal, use them; otherwise **error** `socket_yml_ambiguous` naming both. The existing consumers disagree on which file wins, so we refuse to pick. | - -**Error behavior:** before any write, `scan` exits **1** with -`errorCode: socket_yml_invalid` (or `socket_yml_ambiguous`), a human -message naming file, key path and remedy (fix the file, or -`--no-socket-yml`), `--json` stdout still a valid envelope. Exit 1, not 2: -it is a bad input file, like an invalid manifest, not a usage error. - -**Forward compatibility.** A strict parser means an older pinned CLI fails -on a key a newer CLI understands. That is deliberate (the alternative is a -silently wider rollout); the error text says "unknown key … (a newer -socket-patch may support it; upgrade or remove it)". The contract documents -that keys are only ever added in minor releases and never change meaning. +| No file; empty or comment-only file | no file: defaults | +| Not a regular file after resolving (directory, FIFO, device), resolves outside the repo root, larger than 64 KiB (read at most 64 KiB + 1 from the opened handle; metadata from the same handle) | **error** | +| Invalid UTF-8, UTF-16, NUL bytes (a UTF-8 BOM is stripped; CRLF is fine) | **error** | +| YAML syntax error, duplicate key, top level not a mapping, nesting deeper than 32 | **error** | +| An anchor, alias or merge key (`<<`) inside `patches` or `projectIgnorePaths` | **error** (bounds expansion; nobody needs them here) | +| Top-level key equal to `patch` or `patches` ignoring case but not exactly `patches` | **error**: a misspelled block must not mean "no policy" | +| `patches` present and `version` is not 2 (integer 2 or string `"2"`, as ajv coerces), including missing | **error** ("patches requires version: 2") | +| `patches: null` or `patches: {}` | defaults | +| Unknown key under `patches` | **error**, with a did-you-mean hint (edit distance <= 2) and "a newer socket-patch may support it; upgrade or remove it" | +| Wrong type (no coercion: `"false"` is not a bool; YAML 1.2, so `no` is a string), unknown severity, `maxNewPatches` not an integer in range, empty allowlist, invalid pattern or spec, a list over 1000 entries, an entry over 1024 bytes | **error** naming the key path (`patches.minSeverity`) | +| `projectIgnorePaths` with a `patches` block present: a string is coerced to a one-element list (as ajv does); anything else not a list of strings is an **error** | | +| `projectIgnorePaths` with **no** `patches` block: same coercion; otherwise warning `socket_yml_ignored_value` and the key is ignored | repos that never opted in do not start failing on a scanner key | +| No `patches` block, any `version` | `projectIgnorePaths` honored whatever the version, as the backend (P2) does | +| Both `socket.yml` and `socket.yaml` at the root | validate both (either invalid is an error). If their `projectIgnorePaths` and `patches` are equal as parsed values (order-sensitive), use `socket.yml`; otherwise **error** `socket_yml_ambiguous`. The existing consumers disagree on which file wins, so we refuse to pick. | +| Only a case variant exists (`Socket.yml`) | not read (the name must match a directory entry exactly, via `read_dir`, so case-insensitive disks behave like the memory tree); warning `socket_yml_name_case` | + +**Error behavior.** Before any write, `scan` fails with exit **1** and +`errorCode: socket_yml_invalid` (or `socket_yml_ambiguous`). The message +names the file, the key path and the remedy (fix the file, or +`--no-socket-yml`). Exit 1, not 2: it is a bad input file, like an invalid +manifest. Scan's JSON is still the legacy shape (not the unified envelope): +the error output is scan's existing error object `{"status": "error", +"error": ""}` plus an additive `"errorCode"`; no `policy` or +`rollout` block is emitted on error. + +Every string copied from the file into output (patterns, specs, key names) +is truncated to 200 characters with control characters stripped; depscan +additionally renders them as escaped code spans (7.3). + +Keys are only ever added in minor releases and never change meaning. An +older pinned CLI fails on a newer key by design, and the error says so. ### 4.5 Lookup -1. Repo root := the nearest ancestor of `--cwd` (inclusive) containing - `.git` (a directory, or a file for worktrees and submodules). With no - `.git` ancestor, repo root := `--cwd` (never the home directory or - filesystem root; socket-cli's unbounded walk could pick up an untrusted - `/tmp/socket.yml`). +1. Canonicalize `--cwd`. Repo root := the nearest ancestor (inclusive) + containing `.git` (a directory, or a file for worktrees and submodules), + not walking past any directory in `GIT_CEILING_DIRECTORIES`, and, on + Unix, only if `.git` is owned by the current user or root (git's + safe.directory spirit; otherwise warning `socket_yml_repo_untrusted` + and the walk stops). With no qualifying `.git`, repo root := `--cwd`. + Never the home directory unless `--cwd` is it; never above `--cwd` + without a `.git`. 2. Read `/socket.yml` and `/socket.yaml` only. - Nested `socket.yml` files are not read. One file per repo, as in the - GitHub App. -3. The in-memory engine's repo root is the tree root it was given. + Nested files are never read (one file per repo, as in the GitHub App). + A symlinked socket.yml is followed only if it resolves to a regular + file inside the repo root. +3. In memory, the repo root is the tree root; the file must arrive with + content (7.2). A socket.yml the tree lists but the engine never + receives, or receives only as present-without-content (symlink, + oversize, LFS pointer, binary), is `socket_yml_invalid`, never absent. 4. `--global` / `--global-prefix` scans have no repo and ignore the file. ### 4.6 Commands @@ -275,7 +338,7 @@ that keys are only ever added in minor releases and never change meaning. | Command | Policy | |---|---| | `scan` (hosted, vendored, agent; wet and `--dry-run`), `hosted-bundle`, the napi engine | honor filters and limit | -| `get` | explicit intent: ignores filters and limit; warns `policy_bypassed` when the target would have been filtered | +| `get` | explicit intent: ignores filters and limit; warns `policy_bypassed` when the target would have been filtered; never fails on the policy (an invalid file just skips the warning) | | `apply`, `list`, `vex`, `rollback`, `remove`, `repair`, `vendor` (eject/revert) | ignore it: they report, attest or undo existing state | **Narrowing never removes.** The policy runs after the prune universe is @@ -287,14 +350,18 @@ over; left byte-identical. It is reported under `policy.retained[]` with `upgradeAvailable`. Removing a patch is only ever `rollback`/`remove`, or the dependency leaving the lockfile. -`minSeverity` filters **candidates** before per-package ranking (so a -lower-ranked patch above the floor can still win), using the patch's real -severity (`severity_order` on `BatchPatchInfo.severity`, or -`max_severity_order` over `vulnerabilities`), never `RankKey.severity`. -With a floor set, a patch with unknown severity is filtered (fail closed). -A recorded patch below the floor stays in place; it is replaced only when a -candidate above the floor supersedes it under the existing -`batch_supersedes` rule, which is an ordinary upgrade. +**Severity floor.** One data source: the by-package records the selector +already fetches (`fetch_patch_details` on disk, the provider's by-package +lookup in memory), severity = `max_severity_order` over the patch's +`vulnerabilities`, never `RankKey.severity` (forced to 0 for merged +patches) and never the batch list. The floor restricts which candidates +may **win** per-package ranking; a lower-ranked patch above the floor can +still win. With a floor set, unknown severity is filtered (fail closed; +note `minSeverity: low` therefore drops unknown-severity patches, which the +recipes say). Supersession of a recorded patch is judged against the +**unfiltered** offer list (5.1): the floor never turns a recorded patch +into "no longer offered". A recorded package with no candidate above the +floor keeps its recorded patch (ALREADY). `enabled: false`: discovery and the table still run; nothing is written; every candidate is reported filtered with `policy_disabled`; warning @@ -302,154 +369,214 @@ every candidate is reported filtered with `policy_disabled`; warning ### 4.7 JSON (`policy` block, owned by A) -Additive top-level key on every `scan --json` result (MINOR), always -present: +Additive top-level key on every successful `scan --json` result (MINOR), +always present. Policy warnings go to scan's top-level `warnings[]`. ```json "policy": { - "source": "file", // "none" | "file" | "bypassed" - "path": "socket.yml", // repo-relative; null unless source=file - "sha256": "…", // of the file bytes; null unless source=file + "source": "file", + "path": "socket.yml", + "sha256": "…", "enabled": true, - "minSeverity": {"value": "high", "source": "file"}, // value null = no floor; source flag|env|file|default + "minSeverity": {"value": "high", "source": "file"}, "counts": {"filtered": 3, "retained": 1}, "filtered": [ - {"purl": "pkg:npm/qs@6.5.2", "uuid": "…", "project": "services/legacy", + {"purl": "pkg:npm/qs@6.5.2", "uuid": null, "project": "services/legacy", "reason": "policy_path_excluded", "detail": "/legacy/ (patches.ignorePaths)"} ], "retained": [ - {"purl": "pkg:npm/lodash@4.17.20", "project": ".", "recordedUuid": "…", + {"purl": "pkg:npm/lodash@4.17.20", "project": "", "recordedUuid": "…", "reason": "policy_package_ignored", "upgradeAvailable": true} ] } ``` -- `uuid` is null when the package was filtered before any patch was looked - up (path, ecosystem, package reasons). +| `source` | When | `path` / `sha256` | +|---|---|---| +| `none` | no file, empty file, file ignored (`--global`), or only a case variant | null | +| `file` | a root file was read (with or without a `patches` block) | the file used (`socket.yml` when both are equal) / its bytes' hash | +| `bypassed` | `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` | null | + +- `project` is the repo-relative root directory; the repo root is `""` + (the memory engine's spelling) everywhere. +- `minSeverity.source` is `flag|env|file|default`; `value` null = no floor. +- `uuid` is null when the package was filtered before any patch lookup + (path, ecosystem, package reasons). A root filtered as a whole is one + entry with `purl: null`. +- `counts.filtered` counts entries of `filtered[]`; `counts.retained` + counts entries of `retained[]`. - Reason codes (stable): `policy_disabled`, `policy_path_excluded`, `policy_path_not_included`, `policy_ecosystem`, `policy_package_not_listed`, `policy_package_ignored`, `policy_severity` (detail `unknown < high` or `medium < high`). -- A root filtered as a whole is one entry with `purl: null`. -- Human output: one line, e.g. - `Policy (socket.yml): 3 skipped by filters, 1 patched package held.` - and `--verbose` lists them. +- Human output: one line, e.g. `Policy (socket.yml): 3 skipped by filters, + 1 patched package held.` Filtered critical/high candidates are always + named on the human path (a policy must not silently hide them); + `--verbose` lists everything. ## 5. Per-run limit (work item B) ### 5.1 Classification -After filtering and per-package selection, each selected `(project root, -purl, uuid)` row is classified against that project's recorded view -(`merge_ledger_records_for_updates`: manifest > hosted lockfile pins > -vendor ledger), with `detect_updates`' qualifier-twin handling: +**Recorded view.** Always the merged view, in every mode and both engines: +`merge_ledger_records_for_updates` (manifest > hosted lockfile pins > +vendor ledger), scoped to the lockfiles and state files of the project +root being written. The in-memory engine reads the same three stores from +the tree (`.socket/manifest.json`, `.socket/vendor/state.json`, hosted +pins discovered from the in-memory lockfiles; new, B). When the lockfiles +of one root pin a purl to different uuids, the recorded uuid is the +selected uuid if it is among them, else the smallest (today's rule). + +**Supersession** uses the by-package records (the same data as selection +and the severity floor), with the `batch_supersedes` rungs applied to +them: merged over unmerged, higher severity between unmerged, a real, +strictly later publish date. It is judged against the **unfiltered** offer +list. B adds the by-package twin of `batch_supersedes` in `ranking.rs`; +`detect_updates` and scan's `updates[]` switch to it so classification, +selection and reporting can never disagree. -| Class | Rule | Counts toward the cap | -|---|---|---| -| ALREADY | recorded uuid == selected uuid, or recorded uuid kept because the selection does not supersede it (`batch_supersedes`) | no; hosted re-confirms it idempotently as today | -| UPGRADE | recorded uuid differs and the selection supersedes it (existing `detect_updates` rule, including "recorded patch no longer offered") | no | -| NEW | no patch recorded for this base purl **in this project root** | **yes** | - -- NEW is per project root. Widening `includePaths` from a pilot directory - to more services makes piloted packages NEW in the added roots, and they - go through the cap again. A patch already in another project is not a - free pass. -- UPGRADEs are exempt (decision): rollout risk is about whether a package - runs patched code at all, and an upgrade fixes more in a package that is - already patched. Capping upgrades would leave known-superseded patches in - place. To freeze everything, use `enabled: false`; `maxNewPatches: 0` - freezes new packages only. +After filtering and per-package selection, each selected `(project root, +purl)` row is: -### 5.2 Budget and ordering +| Class | Rule | Counts toward the cap | Writer receives | +|---|---|---|---| +| ALREADY | recorded uuid == selected uuid, or the selection does not supersede the recorded uuid | no | the **recorded** uuid (re-confirmed idempotently) | +| UPGRADE | the selection supersedes the recorded uuid, or the recorded uuid is no longer offered at all (unfiltered) | no | the selected uuid | +| NEW | nothing recorded for this base purl in this project root | **yes** | the selected uuid, if admitted | +- NEW is per project root. Widening `includePaths` makes piloted packages + NEW in the added roots, so they go through the cap again. +- UPGRADEs are exempt (decision): rollout risk is about whether a package + runs patched code at all, and an upgrade fixes more in an already-patched + package. `enabled: false` freezes everything; `maxNewPatches: 0` freezes + new packages only. +- **Known limit: version bumps.** When a dependency moves to a new version + its hosted pin goes with the old lockfile entry, so the new version is + NEW and goes through the cap. (Hosted state cannot tell a bump from a new + package.) Documented. +- **Qualifier twins** (wheel/sdist, gem platforms) share a base purl. If + one twin lands and another was ineligible, the next run sees the base + purl as recorded and the late twin lands as ALREADY/UPGRADE, uncapped. + Documented; it is one package. + +### 5.2 Eligibility, budget and ordering + +- **Eligibility is decided by the planning pass**, the same pass + `--dry-run` runs, before any budget is spent. A NEW row is eligible only + if every check that can be decided without writing passes: + - tier filter; + - agent partition (vendored / not installed); + - vendored preflight; + - hosted reference grant `granted`, with a usable purl and url; + - vlt artifact preflight; + - symlink refusals; + - rewriter planning shows at least one lockfile edit that would pin it + (no refusal, entry found). + + Ineligible rows keep their existing skip reasons and never hold a slot, + so a patch that cannot land can never stall the rollout. +- **One fetch strategy.** References are requested for every eligible-so-far + candidate (NEW, UPGRADE and ALREADY) in the run's normal batches, before + budgeting; never lazily in rank order. A reference or lookup failure that + affects only rows that end up deferred never fails the run or the root; + it becomes warning `rollout_reference_failed`. +- **Incomplete data.** With a finite cap, if any batch, detail or reference + lookup failed for a package that could have been NEW, no NEW row is + admitted this run (all NEW rows deferred) and warning + `rollout_incomplete_lookup` is emitted. Otherwise a failure would let + lower-ranked patches take the missing ones' slots. ALREADY and UPGRADE + rows proceed as today. - **Unit:** a distinct **base purl** (ecosystem + name + version, - qualifiers stripped) among NEW rows, run-wide: across every project root - of one invocation (disk multi-directory human runs, every root of the - in-memory engine). Admitting a base purl admits all of its NEW rows in - every root. One package patched in ten roots costs 1. -- **Order** (ascending; a total order with no time-dependent keys): - 1. in-flight first (in-memory option `inFlightPatches` only, 7.2; - absent on the CLI) - 2. real severity of the selected patch (`severity_order`: critical, - high, medium, low, unknown) + qualifiers stripped, via one shared core function `canonical_base_purl`) + among eligible NEW rows. Admitting a base purl admits all of its eligible + NEW rows in every root of the invocation; it costs 1 slot. +- **Scope of the budget:** + - in-memory engine: one budget across all roots (collect, plan, apply); + - disk: one budget per invocation. `run_project_dirs` visits + directories in sorted order and passes the **remaining** budget to + each; each directory spends it in rank order. `scan --json` accepts one + directory, so a CI job per directory gets N per directory. Documented. +- **Order** (ascending; total; no time-dependent keys): + 1. in-flight first (in-memory option `inFlightPatches` only, matched by + base purl; absent on the CLI) + 2. severity of the selected patch (`max_severity_order`: critical, high, + medium, low, unknown) 3. advisory count, descending (merged patches first within a severity) 4. ecosystem `cli_name`, ascending - 5. canonical base purl, ascending bytewise (one shared core - normalization function, used by disk and memory) - 6. uuid, ascending - - A base purl present in several roots uses the minimum key of its rows. - `publishedAt` is deliberately **not** a key: the batch endpoint omits it, - so using it would reorder the top N between runs and between the disk - and memory engines. Per-package ranking (which patch a package gets) - still uses `publishedAt` as today; this order only decides which - packages go first. -- **Eligibility before budget.** A row consumes budget only if it can land - this run: it passed the tier filter, the agent partition (vendored / - not installed), the vendored preflight and, in hosted mode, its reference - grant came back `granted`. Rows that cannot land (withdrawn, - build_failed, pending_build, not_found, forbidden, refused) keep their - existing skip reasons and do not hold a slot, so a permanently broken - patch can never stall the rollout. Implementations may fetch references - for every NEW candidate, or in rank-ordered batches until the budget is - full; the resulting plan must be identical. -- **Write failures** after admission consume budget (the run stays bounded; - no backfill within a run). They are reported as failures, as today. + 5. canonical base purl, ascending bytewise + 6. smallest selected uuid across the base purl's rows, ascending + + A base purl in several roots uses the minimum key over its rows. + `publishedAt` is not a key: it would reorder the queue whenever a date is + missing. Per-package ranking (which patch a package gets) still uses + `publishedAt` as today; this order only decides which packages go first. +- **Write failures** after admission (I/O at commit time) consume budget + and are reported as failures. No backfill within a run, so `--dry-run` + predicts the wet run exactly. - **`maxNewPatches: 0`** admits no NEW rows; ALREADY and UPGRADE proceed. -- Everything NEW beyond the budget is **deferred**: not written, not - downloaded, not vendored, reported with its rank. +- Everything eligible and NEW beyond the budget is **deferred**: not + written, not downloaded, not vendored, reported with its rank. ### 5.3 Convergence and determinism - Same inputs, same plan, same bytes. The limit is stateless: run k lands the top N; on run k+1 they are ALREADY and the next N land. M waiting - patches take ceil(M/N) committed runs. + patches take **at most** ceil(M/N) committed runs, absent new or + ineligible patches. - A newly published or re-scored higher-severity patch moves ahead of the - queue. That is intended ("most critical first") and is visible because + queue. That is intended ("most critical first") and visible, because every deferred entry carries its rank and severity. - Low-severity patches can wait indefinitely while higher ones keep arriving. Documented; it is the point of severity ordering. - **CI that does not commit** the scan's result never advances recorded - state, so a cap there means "only the top N, every run". Documented in - the recipes: commit the lockfile changes (or use a PR bot), or do not set - a cap in non-committing jobs. + state, so a cap there means "only the top N, every run". The recipes + say: commit the lockfile changes (or use a PR bot), or set no cap in + non-committing jobs. - `pending_build` references are transient: a row can be ineligible one run and eligible the next. The plan is still a function of the inputs. +- `--dry-run` fetches reference grants like a wet run (it must, to decide + eligibility), so it has the same server-side effects a dry run has + today. ### 5.4 Modes -| Mode | Recorded state | NEW/ALREADY/UPGRADE source | Deferred rows | -|---|---|---|---| -| hosted (disk) | lockfile hosted pins (`HostedPin`) | recorded view | never granted, never rewritten; mirrored into `redirect.skipped[]` with reason `rollout_deferred` | -| vendored | `.socket/vendor/state.json` | ALREADY = `already_vendored`, UPGRADE = `would_revendor` | never downloaded or vendored | -| agent | `.socket/manifest.json` | ALREADY = `skipped`, UPGRADE = `updated` | never downloaded; not in `apply.patches[]` | -| in-memory (napi, hosted-bundle) | hosted pins discovered from the in-memory lockfiles (**new**, B) | same | in `ProjectResult.deferred[]` and `skipped[]` with `rollout_deferred` | +| Mode | ALREADY / UPGRADE surface | Deferred rows | +|---|---|---| +| hosted (disk) | re-confirmed / rewritten, as today | never rewritten; mirrored into `redirect.skipped[]` with reason `rollout_deferred` | +| vendored | `already_vendored` / `would_revendor` | never downloaded or vendored | +| agent | `skipped` / `updated` | never downloaded; not in `apply.patches[]` | +| in-memory (napi, hosted-bundle) | as hosted | in `ProjectResult.deferred[]` and `skipped[]` with `rollout_deferred` | -`--dry-run` makes exactly the same decisions and reports them the same way. -A takeover of an existing vendored or hosted entry counts as recorded, not -NEW. +Recorded state is the merged view (5.1) in every row. A takeover of an +existing vendored or hosted entry counts as recorded, not NEW. `--dry-run` +makes exactly the same decisions. ### 5.5 JSON (`rollout` block, owned by B) -Additive top-level key on every `scan --json` result (MINOR), always -present: +Additive top-level key on every successful `scan --json` result (MINOR), +always present: ```json "rollout": { - "maxNewPatches": {"value": 5, "source": "file"}, // value null = unlimited; source flag|env|file|default|cap + "maxNewPatches": {"value": 5, "source": "file"}, "counts": {"new": 5, "deferred": 9, "upgrade": 1, "already": 12}, "deferred": [ - {"purl": "pkg:npm/minimist@1.2.5", "uuid": "…", "severity": "critical", + {"purl": "pkg:npm/minimist@1.2.5", "uuids": ["…"], "severity": "critical", "advisoryCount": 1, "projects": ["services/api", "services/web"], "rank": 6} ] } ``` -- `counts.new` is the number of NEW base purls admitted this run - (landed, or would land under `--dry-run`); `deferred` lists the rest in - rank order; `rank` is 1-based across all NEW candidates. -- Human output (hosted/vendored/agent summary, then the Next-steps - renderer): +- `maxNewPatches.value` null = unlimited; `source` is + `flag|env|file|default|cap`. +- `counts.new` and `counts.deferred` count base purls (admitted this run, + or would be under `--dry-run`; deferred). `counts.upgrade` and + `counts.already` count `(project, purl)` rows. +- `deferred[]` is in rank order; `purl` is the base purl; `uuids` lists + the distinct selected uuids across its rows and qualifier twins; `rank` + is 1-based among **eligible** NEW base purls. Ineligible rows are not + ranked; they appear under their existing skip reasons. +- Human output (after the mode's summary, then the Next-steps renderer): ``` Rollout: 5 of 14 new patches applied (maxNewPatches=5 from socket.yml); 1 upgrade, 12 already applied. @@ -470,11 +597,11 @@ patches: ``` ```yaml -# R2 Critical first: critical only, then widen by editing one line +# R2 Critical first: widen by editing one line version: 2 patches: - minSeverity: critical # later: high, then remove - maxNewPatches: 5 + minSeverity: critical # later: high, then low, then remove the key + maxNewPatches: 5 # (low still skips patches whose severity is unknown) ``` ```yaml @@ -511,6 +638,9 @@ patches: # maxNewPatches: 0 ``` +A cap only advances when the scan's changes are committed (or merged by a +PR bot). In a CI job that scans without committing, set no cap. + One-off overrides from the command line: `socket-patch scan --max-new-patches none` (drain the queue this run), `--min-severity none`, `--no-socket-yml` (ignore the file entirely). @@ -520,29 +650,36 @@ One-off overrides from the command line: `socket-patch scan ### 7.1 Behavior with the new engine - `repo` jobs rebuild one commit from the base SHA each run. With - `maxNewPatches: 5`, "recorded" means pinned on the **base** branch, so + `maxNewPatches: 5`, "recorded" means recorded on the **base** branch, so every rebuild proposes the same top 5 until the PR merges, then the next - 5. No churn, no new PR per batch. -- `pull_request` jobs honor the filters but pass `maxNewPatches: "none"`: - deferring there would leave the check permanently showing work. -- socket.yml is read from the same commit as the tree (base SHA for `repo` - jobs, head SHA for `pull_request` jobs), through the engine: the file is - one of the paths the engine asks for, so there is no second parser. -- Effective limit = min(repo value or override, server cap). The server - can tighten, never loosen. Org-level kill switches, entitlement and - safety (D1-D6) always win. + 5. `inFlightPatches` (the base purls already in the open PR) keeps a + reviewed patch from being displaced by a newly published one mid-review. +- **Policy source.** Both job kinds read socket.yml from the **base** SHA: + the reviewed, merged policy. A pull request cannot loosen the policy + that judges its own check (for example by adding `ignorePackages` for + the vulnerable dependency it introduces). If the PR head changes + `patches` or `projectIgnorePaths`, the check run says so and lists what + the head's policy would additionally filter. +- `pull_request` jobs honor the filters and pass `maxNewPatches: "none"` + and **no** `maxNewPatchesCap`: deferring there would leave the check + permanently showing work. +- Effective limit for `repo` jobs = min(repo value, `maxNewPatchesCap`). + The server can tighten, never loosen; the cap applies to every value + including `"none"`. Org-level kill switches, entitlement and safety + (D1-D6) always win. ### 7.2 Engine API changes (napi `HostedScanOptions` / result, and the `hosted-bundle` harness) | Owner | Change | |---|---| -| A | `selectHostedScanPaths` returns root `socket.yml` / `socket.yaml` when present in the tree listing (one phase: the file is small and root-only; roots the policy excludes are simply not processed) | -| A | options `noSocketYml?: boolean`, `minSeverity?: "critical"\|"high"\|"medium"\|"low"\|"none"` | -| A | result: session-level `policy` block (4.7) and `policyError?: {code, detail}`; on error no project is processed and no files change; `skipped[].reason` gains the `policy_*` codes | -| B | options `maxNewPatches?: number \| "none"`, `maxNewPatchesCap?: number`, `inFlightPatches?: string[]` (uuids already in the open PR; ranked first so a reviewed patch is not displaced by a newly published one mid-review) | +| A | `selectHostedScanPaths` also returns root `socket.yml` / `socket.yaml` when listed, and returns the list of policy paths it selected; it applies only the **built-in** default ignores (it cannot see file contents); the session fails `socket_yml_invalid` if a selected policy path never arrives with content or arrives present-without-content | +| A | the session applies the full policy to detected roots **before** the `max_projects` check (`hosted_memory/mod.rs:377`) | +| A | options `noSocketYml?: boolean`, `minSeverity?: "critical"\|"high"\|"medium"\|"moderate"\|"low"\|"none"` | +| A | result: session-level `policy` block (4.7) and `policyError?: {code, detail}`; on error no root is processed and no files change; `skipped[].reason` gains the `policy_*` codes | +| B | options `maxNewPatches?: number \| "none"`, `maxNewPatchesCap?: number`, `inFlightPatches?: string[]` (base purls) | | B | result: session-level `rollout` block (5.5); `ProjectResult.deferred[]`; `skipped[]` rows with `rollout_deferred` | -| B | hosted-pin discovery over the in-memory lockfiles (the memory twin of `HostedPin::all(discover_wiring(..))`), so NEW/ALREADY/UPGRADE work in memory. A finite cap must never ship in the engine without it: every merged pin would look NEW and the rollout would stall at N. | -| B | restructure the per-root loop at `hosted_memory/mod.rs:537` into collect all roots → plan once → apply, so the budget is run-wide | +| B | hosted-pin discovery over the in-memory lockfiles and reading `.socket/manifest.json` / `.socket/vendor/state.json` from the tree, for the merged recorded view. A finite cap must never ship in the engine without it: every merged pin would look NEW and the rollout would stall at N. | +| B | restructure the per-root loop around `hosted_memory/mod.rs:537` into collect all roots → plan once → apply, so the budget is run-wide | `hosted-bundle` rejects unknown fields, so each owner adds its fields there too. @@ -550,26 +687,34 @@ too. ### 7.3 depscan follow-up (after A and B merge; separate PR in depscan) 1. Bump the socket-patch submodule and rebuild the addon. -2. Pass `inFlightPatches` (uuids in the open patch-all PR) and, for - `pull_request` jobs, `maxNewPatches: "none"`. -3. New job outcome `policy_invalid` (from `policyError`): leave the +2. Stream root socket.yml content from the **base** SHA for both job kinds + (for `repo` jobs that is the tree being scanned; for `pull_request` + jobs push the base-SHA blob under the policy path the engine selected). + Never let the file be dropped by the size/path caps silently: the + engine turns a missing policy blob into `policyError`. +3. Pass `inFlightPatches` (base purls in the open patch-all PR); for + `pull_request` jobs pass `maxNewPatches: "none"` and no cap. +4. New job outcome `policy_invalid` (from `policyError`): leave the existing PR untouched, surface the error on the admin page and in the - job's check-run text. -4. Render a "Deferred (next batch)" table and severity/rank columns in the - PR body; add `patchesDeferred` to stats. -5. Optional server cap per org (future org setting), passed as - `maxNewPatchesCap`; intersect the admin `config.ecosystems` (D3) with - the file by passing it as `ecosystems` as today. -6. Do **not** add `patches` to the ajv schema in - `socket-yaml-schema.ts` with strict types: a typo would reject the whole - file and turn PR checks neutral. If documentation value is wanted, add - it as a permissive `{type: object}`. -7. Docs repo: add a `patches` section to the socket.yml page, and fix the - two stale statements found in research (which file wins when both - exist; v1 files are rejected by the GitHub App). - -A closed/rejected rolling PR re-proposes the same patches next run; -document `ignorePackages` as the way to decline one. + check-run text. +5. PR body and check run: a "Deferred (next batch)" table with severity + and rank; `policy.filtered`/`retained` counts, naming every critical or + high candidate the policy suppressed; a note when the PR head changes + the policy. Render every file-derived string as an escaped code span, + truncated. +6. Stats: `patchesDeferred`, `patchesFiltered`. +7. Optional server cap per org (future setting) passed as + `maxNewPatchesCap`; keep passing the admin `config.ecosystems` (D3) as + `ecosystems`, which intersects with the file. +8. Do **not** add `patches` with strict types to the ajv schema in + `socket-yaml-schema.ts`: a typo there rejects the whole file and turns + PR checks neutral. If wanted for docs, add a permissive `{type: object}`. +9. Docs repo: a `patches` section on the socket.yml page; fix the two + stale statements found in research (which file wins when both exist; + v1 files are rejected by the GitHub App). + +A closed or rejected rolling PR re-proposes the same patches next run; +`ignorePackages` is the documented way to decline one. ## 8. Decisions log @@ -577,46 +722,48 @@ document `ignorePackages` as the way to decline one. |---|---|---| | 1 | Top-level `patches:` in socket.yml v2; no `version: 3` | breaks no parser (P1/P2 strip, P3 ignores); P3 rejects any version but 2 | | 2 | socket-patch reads socket.yml (reverses configuration.md) | owner request; policy that only narrows fits the trust boundary | -| 3 | Keys `enabled includePaths ignorePaths ecosystems packages ignorePackages minSeverity maxNewPatches` | include/ignore pairs mirror existing keys; `packages` allowlist covers single-package pilots; `maxNewPatches` says it counts new patches only | -| 4 | gitignore semantics via the `ignore` crate, case-insensitive, anchored at repo root | identical to `projectIgnorePaths` in the backend | -| 5 | socket-patch also honors `projectIgnorePaths` | users expect one ignore list; every other consumer already honors it | -| 6 | Defaults `test/ tests/ fixtures/ __fixtures__/ testdata/` evaluated first, overridden by `!`; discovered roots only | moves H1; replace-on-set would re-enable fixtures when someone adds one unrelated pattern | -| 7 | Strict validation, fail closed, exit 1 `socket_yml_invalid` | a broken narrowing rule must not widen the rollout | -| 8 | Both files: error only if the parts we read differ | existing consumers disagree on precedence; repos that already have both keep working | -| 9 | Repo root = nearest `.git` ancestor, else `--cwd`; root files only | matches the GitHub App; memory engine can mirror it; never reads outside the checkout | +| 3 | Keys `enabled includePaths ignorePaths ecosystems packages ignorePackages minSeverity maxNewPatches` | include/ignore pairs mirror existing keys; `packages` covers single-package pilots; `maxNewPatches` says it counts new patches only | +| 4 | Paths match marker **files**, npm-`ignore` semantics, top-down, case-insensitive; golden parity fixture | identical meaning to `projectIgnorePaths` in the backend; no root special form | +| 5 | socket-patch also honors `projectIgnorePaths` (leniently when there is no `patches` block) | users expect one ignore list; repos that never opted in do not start failing | +| 6 | Defaults `test/ tests/ fixtures/ __fixtures__/ testdata/` first, overridden by `!`; discovered roots only | moves H1; replace-on-set would re-enable fixtures on any unrelated edit | +| 7 | Strict validation of `patches`, fail closed, exit 1 `socket_yml_invalid` | a broken narrowing rule must not widen the rollout | +| 8 | Both files: error only if the parts we read differ | consumers disagree on precedence; repos that have both keep working | +| 9 | Repo root = nearest trusted `.git` ancestor (ceiling dirs honored), else `--cwd`; root files only; PATHs outside it are exit 2 | matches the GitHub App; memory can mirror it; never reads outside the checkout | | 10 | Flags intersect lists; scalars CLI > env > file > default; `--no-socket-yml` with env | contract precedence and "every flag has an env var" | | 11 | `maxNewPatches: 0` = upgrades only; absent / `none` = unlimited | literal meaning; avoids the Dependabot/Renovate 0 disagreement | | 12 | Unknown severity is filtered when a floor is set | fail closed | -| 13 | NEW per (project root, base purl); budget per base purl run-wide | a widened pilot re-enters the cap; one package in many roots costs 1 | -| 14 | Upgrades exempt from the cap | rollout risk is per package; keeps patched packages current | -| 15 | Order: severity, advisory count, ecosystem, base purl, uuid; no `publishedAt` | total and time-independent; batch lacks the date | -| 16 | Budget after eligibility (grants, partition, preflight) | a withdrawn/broken patch never holds a slot | -| 17 | Filtered packages with recorded patches are retained, never removed or upgraded | narrowing freezes, never removes | -| 18 | `get` bypasses the policy with a warning | explicit intent | -| 19 | Separate `policy` (A) and `rollout` (B) JSON blocks | clean ownership seam; both additive | -| 20 | Everything ships in 5.0 | honoring `projectIgnorePaths`, disk default ignores and fail-closed file errors change scan's default behavior (MAJOR) | +| 13 | One data source (by-package records) for floor, supersession, classification and order | selection, classification and reporting can never disagree | +| 14 | NEW per (project root, base purl); budget per base purl; memory run-wide, disk per invocation carried across directories | a widened pilot re-enters the cap; one package in many roots costs 1 | +| 15 | Upgrades exempt from the cap | rollout risk is per package; keeps patched packages current | +| 16 | Order: severity, advisory count, ecosystem, base purl, uuid; no `publishedAt` | total and time-independent | +| 17 | Eligibility = everything the planning pass can decide; fetch-all references; incomplete lookups admit no NEW rows | a broken patch never holds a slot; failures never reshuffle the queue | +| 18 | Filtered packages with recorded patches are retained, never removed or upgraded | narrowing freezes, never removes | +| 19 | `get` bypasses the policy with a warning | explicit intent | +| 20 | Separate `policy` (A) and `rollout` (B) JSON blocks | clean ownership seam; both additive | +| 21 | depscan reads the policy from the base SHA for PR jobs too | a PR cannot loosen the policy judging it | +| 22 | Everything ships in 5.0 | honoring `projectIgnorePaths`, disk default ignores and fail-closed file errors change scan's default behavior (MAJOR) | ## 9. Work items Both items branch from `release/v5-prerelease` (suggested branches `v5/rollout-policy` for A, `v5/rollout-limit` for B). **Merge order: A, -then B.** -B rebases onto A and owns the final integration (section 9.3). Neither -item depends on the other's types: the only exchanged values are plain -`Option` / `Option` and the pipeline order below. +then B.** B rebases onto A and owns the final integration (9.3). Neither +item needs the other's types to compile: the only exchanged values are +plain integers and the pipeline order below. ### 9.0 Shared contract (frozen by this plan) Scan pipeline, in order (disk and memory): -1. load policy (A) — fail closed before any write +1. load policy (A); fail closed before any write 2. crawl; capture the prune universe (unchanged) 3. root filter, ecosystem/package filter, retained set (A) -4. batch API (unchanged) -5. candidate severity filter (A) +4. batch API, by-package details (unchanged fetches) +5. candidate severity filter on by-package records (A) 6. per-package ranking (unchanged `ranking`) -7. classify NEW/ALREADY/UPGRADE, eligibility, budget, deferral (B) -8. writers (unchanged; receive only admitted rows) +7. classify, planning pass for eligibility, budget, deferral (B) +8. writers (receive only admitted NEW rows, ALREADY rows with the recorded + uuid, and UPGRADE rows) ```rust // crates/socket-patch-core/src/policy/mod.rs — OWNER A @@ -628,20 +775,26 @@ pub enum FilterReason { Severity { found: Option, floor: String }, } impl FilterReason { pub fn code(&self) -> &'static str; pub fn detail(&self) -> String; } -pub enum PolicyError { Invalid { file: String, key: String, message: String }, Ambiguous { files: [String; 2] } } +pub enum PolicyError { + Invalid { file: String, key: String, message: String }, + Ambiguous { files: [String; 2] }, +} impl PolicyError { pub fn code(&self) -> &'static str; } // socket_yml_invalid | socket_yml_ambiguous -pub trait PolicyFs { fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result>>; } +pub enum RootFile { Absent, Present(Vec), PresentWithoutContent } +pub trait PolicyFs { fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result; } pub struct PolicyOverrides { pub bypass: bool, pub min_severity: Option> } // Some(None) = "none" +pub struct Root<'a> { pub rel_dir: &'a str, pub markers: &'a [String], pub explicit: bool } impl SelectionPolicy { - pub fn unrestricted() -> Self; // defaults (built-in path ignores only) - pub fn load(fs: &dyn PolicyFs, o: &PolicyOverrides) -> Result; + pub fn unrestricted() -> Self; // built-in default ignores only + pub fn load(fs: &dyn PolicyFs, o: &PolicyOverrides) -> Result<(Self, Vec), PolicyError>; pub fn source(&self) -> &PolicySource; pub fn enabled(&self) -> bool; - pub fn admits_root(&self, rel_dir: &str, explicit: bool) -> Result<(), FilterReason>; - pub fn admits_purl(&self, purl: &str) -> Result<(), FilterReason>; // ecosystem + packages + pub fn admits_root(&self, root: &Root) -> Result<(), FilterReason>; + pub fn admits_purl(&self, purl: &str) -> Result<(), FilterReason>; // ecosystem + packages pub fn admits_severity(&self, severity_order: u8) -> Result<(), FilterReason>; - pub fn max_new_patches(&self) -> Option; // the FILE value only; B resolves precedence + pub fn max_new_patches(&self) -> Option; // the FILE value only; B resolves precedence } +pub fn package_spec_matches(spec: &str, purl: &str) -> bool; // moved from cli scan/mod.rs:383 // crates/socket-patch-core/src/rollout.rs — OWNER B pub enum Recorded { None, Same, Kept { uuid: String }, Superseded { old_uuid: String } } @@ -656,149 +809,182 @@ pub fn resolve_max_new(flag: Option>, env: Option>, file: Option, cap: Option) -> MaxNew; pub fn canonical_base_purl(purl: &str) -> String; pub fn rollout_cmp(a: &Candidate, b: &Candidate) -> std::cmp::Ordering; -pub struct RolloutPlan { pub admitted: Vec, pub deferred: Vec<(Candidate, u32)>, pub counts: RolloutCounts } -pub fn plan_rollout(candidates: Vec, max_new: &MaxNew) -> RolloutPlan; // pure +pub struct RolloutCounts { pub new: u32, pub deferred: u32, pub upgrade: u32, pub already: u32 } +pub struct RolloutPlan { + pub admitted: Vec, pub deferred: Vec<(Candidate, u32)>, + pub counts: RolloutCounts, pub remaining: Option, // carried to the next directory +} +pub fn plan_rollout(candidates: Vec, max_new: &MaxNew, incomplete: bool) -> RolloutPlan; // pure + +// crates/socket-patch-core/src/api/ranking.rs — OWNER B (addition) +pub fn search_result_supersedes(candidate: &PatchSearchResult, recorded: &PatchSearchResult) -> bool; ``` Rules both items follow: -- Severity input is always the patch's real severity (`severity_order` / - `max_severity_order`), never `RankKey.severity`. -- Skip-reason strings are the stable codes in 4.7 and 5.5. +- Severity input is always `max_severity_order` over the by-package + record's `vulnerabilities`, never `RankKey.severity`, never the batch + list. +- Skip-reason strings are the stable codes in 4.7 and 5.5; warnings go to + scan's top-level `warnings[]`. - JSON: A owns the top-level `policy` block; B owns the top-level `rollout` block. Neither edits the other's. -- CLI args: A adds a `#[command(flatten)]` `SocketYmlArgs` (`--no-socket-yml`, - `--min-severity`) in `scan/socket_yml_args.rs`; B adds a flattened - `RolloutArgs` (`--max-new-patches`) in `scan/rollout_args.rs`. Both - derive `Default`; each adds its field to the ~18 `ScanArgs` struct - literals. The resulting adjacent-line conflicts are resolved by B on - rebase. +- CLI args: A adds a `#[command(flatten)]` `SocketYmlArgs` + (`--no-socket-yml`, `--min-severity`) in `scan/socket_yml_args.rs`; B + adds a flattened `RolloutArgs` (`--max-new-patches`) in + `scan/rollout_args.rs`. Both derive `Default`; each adds its field to + the ~18 `ScanArgs` struct literals. B resolves the adjacent-line + conflicts on rebase. +- `run_project_dirs` changes: A adds the per-directory `explicit` flag; + B adds the carried remaining budget. B resolves the overlap on rebase. ### 9.1 Work item A — socket.yml loading and filtering Scope: - `crates/socket-patch-core/src/policy/{mod.rs, socket_yml.rs, paths.rs}`; - `pub mod policy;` in `crates/socket-patch-core/src/lib.rs`. + `pub mod policy;` in `crates/socket-patch-core/src/lib.rs`; move + `package_spec_matches` to core (the cli re-uses it). - Dependencies, exact-pinned in `Cargo.toml`: a maintained YAML 1.2 serde - crate that reports duplicate keys (e.g. `serde_norway`; verify - duplicate-key rejection with a test, reject it otherwise), and `ignore` - (gitignore matcher). No other new deps. -- Loader: lookup (4.5), size and symlink confinement, both-files rule, - strict validation with key paths and did-you-mean (4.4), `PolicyFs` for - disk and for the in-memory engine. -- Filters, wired at the pipeline points in 9.0: + crate that reports duplicate keys and can refuse aliases and bound depth + (e.g. `serde_norway`; prove each property with a test, pick another + crate otherwise), and `ignore` (for `Gitignore::matched`; the top-down + walk is ours). No other new deps. +- Loader: lookup (4.5, incl. ceiling dirs and ownership), regular-file, + size and symlink confinement on the opened handle, exact-name match, + encoding, both-files rule, strict validation with key paths and + did-you-mean (4.4), `PolicyFs` for disk and memory. +- Path matcher (4.3): marker-file subject, npm-`ignore` top-down + semantics, defaults + lists in order, `includePaths`, pattern hygiene; + golden fixture generated from npm `ignore` (commit the generator script + under `scripts/` and the fixture under `crates/socket-patch-core/tests/`). +- Filters at the pipeline points in 9.0: - disk: root filter in `project_dirs` / `run_project_dirs` - (`scan/mod.rs:1268-1320`) and the agent project; `admits_purl` next to - `--package` (`scan/mod.rs:1490-1511`); severity filter on batch - candidates after `scan/mod.rs:1801` and on by-package candidates before - `select_patches` in the human arm; retained set computed from the - recorded view and excluded from writers. - - memory: root filter in `hosted_memory/roots.rs` root detection; - `admits_purl` at `hosted_memory/mod.rs:428-432`; severity filter before + (`scan/mod.rs:1268-1320`, carrying `explicit`) and the agent project; + `admits_purl` next to `--package` (`scan/mod.rs:1490-1511`); severity + filter on by-package candidates before `select_patches` + (`discover_selected`, `scan/mod.rs:553`, and the human arm, + `mod.rs:2386-2402`); retained set computed from the recorded view and + excluded from writers. + - memory: built-in defaults in `selectHostedScanPaths` + (`hosted_memory/select.rs`); full root filter in the session before + `max_projects` (`hosted_memory/mod.rs:377`); `admits_purl` at + `hosted_memory/mod.rs:428-432`; severity filter before `select_top_ranked`. - Move `test tests fixtures __fixtures__ testdata` out of `EXCLUDED_ROOT_SEGMENTS` (`hosted_memory/roots.rs:56-67`) into the built-in default ignores, and apply them to disk PATH-glob expansion. - `enabled: false` report-only path; `get`'s `policy_bypassed` warning; - `--global` ignores the file. + `--global` ignores the file; PATHs outside the repo root → exit 2. - Flags: `--no-socket-yml`/`SOCKET_NO_SOCKET_YML`, `--min-severity`/`SOCKET_MIN_SEVERITY` (`SocketYmlArgs`). -- napi + hosted-bundle: `selectHostedScanPaths` includes root - `socket.yml`/`socket.yaml`; options `noSocketYml`, `minSeverity`; result - `policy` and `policyError`; `npm/index.d.ts` types. -- JSON `policy` block (4.7), human policy line, error envelopes for - `socket_yml_invalid` / `socket_yml_ambiguous`, warnings - `socket_yml_unsupported_version`, `patches_disabled`, `policy_bypassed`. +- napi + hosted-bundle (7.2, A rows); `npm/index.d.ts` types. +- JSON `policy` block (4.7), human policy line (naming suppressed + critical/high), error output with `errorCode`, warnings + `socket_yml_ignored_value`, `socket_yml_name_case`, + `socket_yml_repo_untrusted`, `patches_disabled`, `policy_bypassed`; + output string hygiene. Tests: -- Unit (core, table-driven): every row of 4.4; gitignore cases (anchoring, - bare names, trailing `/`, `!` and the excluded-parent rule, case - insensitivity, the `/` root form, defaults + negation); package specs; - severity floor incl. unknown and `moderate`; both-files equal/different; - lookup with `.git` dir, `.git` file, no git. -- Parser contract: `tests/cli_parse_scan.rs` rows for the two flags and - env vars. +- Unit (core, table-driven): every row of 4.4 in order; the npm-`ignore` + golden fixture (anchoring, bare names, trailing `/`, `!`, excluded + parents, case); marker rule (all markers ignored / any included); + defaults + negation; explicit vs discovered; package specs incl. + invalid ones; severity floor incl. unknown and `moderate`; both-files + equal / different / one invalid; lookup with `.git` dir, `.git` file, + none, `GIT_CEILING_DIRECTORIES`, foreign-owned `.git`; symlink inside + and outside, directory, FIFO; alias bomb; oversize; BOM, CRLF, UTF-16. +- Parser contract: `tests/cli_parse_scan.rs` rows for both flags and env + vars (empty = unset, malformed = exit 2). - E2E (wiremock, `tests/in_process_scan.rs` style): hosted, vendored, - agent and `--dry-run` with a socket.yml that filters by path, ecosystem, - package and severity; invalid file → exit 1, no bytes changed; - `--no-socket-yml` bypass; narrowing after a patch is applied leaves the - pinned package byte-identical in hosted, vendored and agent modes - (retained); `--prune` universe unchanged. -- Parity: `tests/hosted_memory_parity.rs` gains a socket.yml fixture; disk - and memory filter the same roots and packages. + agent and `--dry-run` with a socket.yml filtering by path, ecosystem, + package and severity; invalid file → exit 1, `errorCode`, no bytes + changed; `--no-socket-yml`; narrowing after a patch is applied leaves the + pinned package byte-identical in all three modes (retained); a recorded + merged patch below a new floor is kept, not replaced; `--prune` universe + unchanged; PATH outside the repo → exit 2. +- Parity: `tests/hosted_memory_parity.rs` gains a socket.yml fixture + (single-lockfile roots) where disk and memory filter the same roots and + packages; a memory test where the tree lists socket.yml but its content + is withheld → `policyError`. - This repo's own `socket.yml` keeps working (its `projectIgnorePaths` now also excludes the fixtures from patching). Docs (A): `CLI_CONTRACT.md` (new "socket.yml patch policy" section: -grammar, precedence, lookup, validation, commands; flag + env rows; error -codes; `policy` JSON block; the trust-boundary bullet gains the "narrow or -pace" sentence), README (scan section: "Roll out gradually" with recipes -R1-R4, R6), CHANGELOG `[Unreleased]` (Added: socket.yml patch policy; -Changed (BREAKING): scan honors `projectIgnorePaths`, default test/fixture -ignores on discovered roots, invalid socket.yml fails scan). +grammar, precedence, paths, lookup, validation, commands; flag + env rows; +error codes; `policy` JSON block; the trust-boundary bullet gains the +"narrow or pace" sentence), README (scan section: "Roll out gradually" +with recipes R1-R4, R6), CHANGELOG `[Unreleased]` (Added: socket.yml +patch policy; Changed (BREAKING): scan honors `projectIgnorePaths`, +default test/fixture ignores on discovered roots, invalid socket.yml with +a `patches` block fails scan). ### 9.2 Work item B — limit, ordering, reporting Scope: - `crates/socket-patch-core/src/rollout.rs`; `pub mod rollout;` in - `crates/socket-patch-core/src/lib.rs`. + `crates/socket-patch-core/src/lib.rs`; `search_result_supersedes` in + `ranking.rs`, and `detect_updates` / `updates[]` switched to by-package + supersession. - Make `discover_selected` (`scan/mod.rs:553`) the single disk selection point: route the human agent/vendored arm (`mod.rs:2386-2402`) through - it, and have it return `{selected, deferred}` so hosted + it, and have it return `{admitted, deferred}` so hosted (`run_redirect_selected`, `hosted.rs:1196`), vendored and agent writers - receive only admitted rows. -- Classification from `merge_ledger_records_for_updates` / - `detect_updates` per project root; eligibility (tier, agent partition, - vendored preflight, hosted reference grants — grants fetched for NEW - candidates in rank order, identical result either way); `plan_rollout` - with one run-wide budget across `run_project_dirs`. -- In-memory engine: hosted-pin discovery over in-memory lockfiles; - collect → plan → apply restructure around `hosted_memory/mod.rs:537`; - options `maxNewPatches`, `maxNewPatchesCap`, `inFlightPatches`; result - `rollout`, `ProjectResult.deferred[]`, `rollout_deferred` skips; - `npm/index.d.ts`; hosted-bundle fields. + receive only the rows 9.0 step 8 allows (ALREADY with the recorded + uuid). +- Classification from the merged recorded view (5.1); the planning pass + for eligibility (hosted: grants, purl/url, vlt preflight, symlink + refusals, rewriter planning; vendored: preflight; agent: partition); + fetch-all references; `rollout_reference_failed` and + `rollout_incomplete_lookup`; `plan_rollout`; the remaining budget + carried through `run_project_dirs` in sorted directory order. +- In-memory engine (7.2, B rows): pin discovery and state-file reads, + collect → plan → apply, options and result fields, `npm/index.d.ts`, + hosted-bundle fields. - Flag: `--max-new-patches `/`SOCKET_MAX_NEW_PATCHES` - (`RolloutArgs`); `resolve_max_new` precedence including the file value - from A (9.3). + (`RolloutArgs`); `resolve_max_new` including the file value from A + (9.3). - JSON `rollout` block (5.5), `redirect.skipped[]` mirror, human "Rollout:" line and the Next-steps deferred line (hosted `format_next_steps`, `hosted.rs:3457`, and the agent/vendored summaries). Tests: -- Unit (core): `rollout_cmp` total order (property: sorting any - permutation gives the same result); `plan_rollout` caps only NEW, - counts base purls run-wide, one package across roots costs 1, 0 = no - NEW, `none` = unlimited, ineligible rows hold no slot, in-flight first; - `resolve_max_new` precedence table incl. cap; `canonical_base_purl` - twins. +- Unit (core): `rollout_cmp` total order (property test: every + permutation sorts the same); `plan_rollout` caps only eligible NEW, + counts base purls, one package across roots costs 1, 0 = no NEW, `none` + = unlimited, ineligible rows hold no slot, `incomplete` admits nothing + NEW, in-flight first, remaining budget; `resolve_max_new` precedence + table incl. cap on `none`; `canonical_base_purl` twins; + `search_result_supersedes` rungs. - E2E (wiremock): hosted, vendored, agent, `--dry-run`: 9 candidates with - `--max-new-patches 3` apply the 3 most severe; rerun on the result - applies the next 3; a third run the last 3; a fourth run changes nothing - (convergence); upgrades land regardless of the cap; a withdrawn - top-ranked patch does not consume budget; JSON `rollout` and - `redirect.skipped[]` contents; exit 0. -- Parity: `hosted_memory_parity.rs` cap fixture — disk and memory admit - and defer the same rows; memory rerun with pins in the lockfiles lands - the next N (needs pin discovery). + `--max-new-patches 3` apply the 3 most severe; a rerun on the result + applies the next 3; a third run the last 3; a fourth changes nothing; + dry-run output equals the wet run's decisions; upgrades land regardless + of the cap; a withdrawn, a `bad_purl` and a vlt-withheld top-ranked + patch hold no slot; a failed detail lookup with a cap admits nothing + NEW; two PATH directories share one budget in sorted order; JSON + `rollout` and `redirect.skipped[]`; exit 0. +- Parity: `hosted_memory_parity.rs` cap fixture: disk and memory admit and + defer the same rows; a memory rerun with pins (and with a committed + manifest / vendor state) lands the next N. - Parser contract rows for the flag and env var. -Docs (B): `CLI_CONTRACT.md` (limit semantics: classification, unit, -order, eligibility, convergence, starvation and non-committing-CI notes; -flag + env rows; `rollout` block; `rollout_deferred`; `jq` recipe; the -"Which patch gets selected" section notes the separate cross-package -order), README (recipe R5, `--max-new-patches`), CHANGELOG `[Unreleased]` -Added. +Docs (B): `CLI_CONTRACT.md` (limit semantics: classification, +eligibility, unit, budget scope, order, convergence, version-bump and +twin notes, starvation and non-committing CI; flag + env rows; `rollout` +block; `rollout_deferred`; warnings; `jq` recipe; "Which patch gets +selected" gains the cross-package order and the by-package supersession +change), README (recipe R5, `--max-new-patches`), CHANGELOG +`[Unreleased]` Added (and Changed: `updates[]` uses by-package data). ### 9.3 Integration (B, after rebasing on A) - Pass `policy.max_new_patches()` as the `file` layer of `resolve_max_new`. -- Resolve the `ScanArgs` struct-literal conflicts (both flattened fields - present). +- Resolve the `ScanArgs` struct-literal and `run_project_dirs` conflicts. - Combined e2e: a socket.yml with `includePaths`, `minSeverity: high` and `maxNewPatches: 2` over a two-root fixture, disk and memory, three runs to convergence; `--no-socket-yml` drops the file's cap but keeps a flag cap. - If B is ready before A merges, B ships with the file layer passed as - `None` and a follow-up commit on its branch wires it once A lands. + `None` and wires it in a follow-up commit on its branch once A lands. ## 10. Open questions (decided by default, revisit with evidence) @@ -808,3 +994,5 @@ Added. exposes them; they would slot between severity and advisory count. - A generated JSON Schema for the `patches` block, shared with depscan and the docs, to keep validators from drifting. +- Recognizing a dependency version bump of an already-patched package as + exempt from the cap (needs state hosted mode does not keep). From bdbe73596d9646b3554291670c90711fe3352acc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:57:12 +0000 Subject: [PATCH 03/14] Close interface gaps in the rollout plan A final consistency pass found places where the two work items would have produced incompatible code: base purls admitted in one directory being charged again in the next, no defined hand-off of the unfiltered offers from the severity filter to classification, no shared repo-relative path helper, and override sources the JSON must report but the interface could not carry. The shared contract now defines each of these, and the parity tests match each engine's budget scope. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/design/staged-rollout.md | 54 ++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index eb834983..c48967d8 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -443,6 +443,7 @@ purl)` row is: | ALREADY | recorded uuid == selected uuid, or the selection does not supersede the recorded uuid | no | the **recorded** uuid (re-confirmed idempotently) | | UPGRADE | the selection supersedes the recorded uuid, or the recorded uuid is no longer offered at all (unfiltered) | no | the selected uuid | | NEW | nothing recorded for this base purl in this project root | **yes** | the selected uuid, if admitted | +| ALREADY (kept) | recorded, offers exist in `Offers.unfiltered` but none survive the floor | no | the recorded uuid (counted in `counts.already`) | - NEW is per project root. Widening `includePaths` makes piloted packages NEW in the added roots, so they go through the cap again. @@ -494,7 +495,9 @@ purl)` row is: - in-memory engine: one budget across all roots (collect, plan, apply); - disk: one budget per invocation. `run_project_dirs` visits directories in sorted order and passes the **remaining** budget to - each; each directory spends it in rank order. `scan --json` accepts one + each, together with the set of base purls already admitted (a base + purl admitted in an earlier directory is admitted free in later ones); + each directory spends the budget in rank order. `scan --json` accepts one directory, so a CI job per directory gets N per directory. Documented. - **Order** (ascending; total; no time-dependent keys): 1. in-flight first (in-memory option `inFlightPatches` only, matched by @@ -747,9 +750,10 @@ A closed or rejected rolling PR re-proposes the same patches next run; Both items branch from `release/v5-prerelease` (suggested branches `v5/rollout-policy` for A, `v5/rollout-limit` for B). **Merge order: A, -then B.** B rebases onto A and owns the final integration (9.3). Neither -item needs the other's types to compile: the only exchanged values are -plain integers and the pipeline order below. +then B.** B rebases onto A and owns the final integration (9.3). The +seams are small and listed in 9.0: the step 5 → step 7 `Offers` struct +(A), the repo-relative path helpers (A), the file's `maxNewPatches` value +(A → B), and the pipeline order. ### 9.0 Shared contract (frozen by this plan) @@ -759,7 +763,9 @@ Scan pipeline, in order (disk and memory): 2. crawl; capture the prune universe (unchanged) 3. root filter, ecosystem/package filter, retained set (A) 4. batch API, by-package details (unchanged fetches) -5. candidate severity filter on by-package records (A) +5. candidate severity filter on by-package records (A); `discover_selected` + returns `Offers` (below) so B sees both the unfiltered and the + floor-filtered candidates 6. per-package ranking (unchanged `ranking`) 7. classify, planning pass for eligibility, budget, deferral (B) 8. writers (receive only admitted NEW rows, ALREADY rows with the recorded @@ -782,7 +788,9 @@ pub enum PolicyError { impl PolicyError { pub fn code(&self) -> &'static str; } // socket_yml_invalid | socket_yml_ambiguous pub enum RootFile { Absent, Present(Vec), PresentWithoutContent } pub trait PolicyFs { fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result; } -pub struct PolicyOverrides { pub bypass: bool, pub min_severity: Option> } // Some(None) = "none" +pub enum OverrideSource { Flag, Env } +pub struct PolicyOverrides { pub bypass: bool, pub min_severity: Option<(Option, OverrideSource)> } // (None, _) = "none" +pub struct PolicyWarning { pub code: &'static str, pub detail: String } pub struct Root<'a> { pub rel_dir: &'a str, pub markers: &'a [String], pub explicit: bool } impl SelectionPolicy { pub fn unrestricted() -> Self; // built-in default ignores only @@ -792,9 +800,17 @@ impl SelectionPolicy { pub fn admits_root(&self, root: &Root) -> Result<(), FilterReason>; pub fn admits_purl(&self, purl: &str) -> Result<(), FilterReason>; // ecosystem + packages pub fn admits_severity(&self, severity_order: u8) -> Result<(), FilterReason>; - pub fn max_new_patches(&self) -> Option; // the FILE value only; B resolves precedence + pub fn max_new_patches(&self) -> Option; // the file's value; None when source() is None or Bypassed, or the key is absent } pub fn package_spec_matches(spec: &str, purl: &str) -> bool; // moved from cli scan/mod.rs:383 +pub fn find_repo_root(cwd: &Path) -> PathBuf; // 4.5 +pub fn repo_relative(repo_root: &Path, dir: &Path) -> String; // "" for the repo root, `/` separators + +// crates/socket-patch-core/src/policy/mod.rs — OWNER A (the step 5 → 7 seam) +pub struct Offers { + pub unfiltered: BTreeMap>, // purl → every offer (after tier) + pub selected: BTreeMap, // purl → winner among floor-admitted offers +} // crates/socket-patch-core/src/rollout.rs — OWNER B pub enum Recorded { None, Same, Kept { uuid: String }, Superseded { old_uuid: String } } @@ -812,9 +828,13 @@ pub fn rollout_cmp(a: &Candidate, b: &Candidate) -> std::cmp::Ordering; pub struct RolloutCounts { pub new: u32, pub deferred: u32, pub upgrade: u32, pub already: u32 } pub struct RolloutPlan { pub admitted: Vec, pub deferred: Vec<(Candidate, u32)>, - pub counts: RolloutCounts, pub remaining: Option, // carried to the next directory + pub counts: RolloutCounts, + pub remaining: Option, // carried to the next directory + pub admitted_base_purls: BTreeSet, // carried too } -pub fn plan_rollout(candidates: Vec, max_new: &MaxNew, incomplete: bool) -> RolloutPlan; // pure +// Rows whose base_purl is in `already_admitted` are admitted without spending budget. +pub fn plan_rollout(candidates: Vec, max_new: &MaxNew, incomplete: bool, + already_admitted: &BTreeSet) -> RolloutPlan; // pure // crates/socket-patch-core/src/api/ranking.rs — OWNER B (addition) pub fn search_result_supersedes(candidate: &PatchSearchResult, recorded: &PatchSearchResult) -> bool; @@ -923,7 +943,9 @@ Scope: - `crates/socket-patch-core/src/rollout.rs`; `pub mod rollout;` in `crates/socket-patch-core/src/lib.rs`; `search_result_supersedes` in `ranking.rs`, and `detect_updates` / `updates[]` switched to by-package - supersession. + supersession; move the `detect_updates` call (today `scan/mod.rs:1912`, + on batch data) after `discover_selected` so it receives the by-package + offers. - Make `discover_selected` (`scan/mod.rs:553`) the single disk selection point: route the human agent/vendored arm (`mod.rs:2386-2402`) through it, and have it return `{admitted, deferred}` so hosted @@ -963,8 +985,10 @@ Tests: patch hold no slot; a failed detail lookup with a cap admits nothing NEW; two PATH directories share one budget in sorted order; JSON `rollout` and `redirect.skipped[]`; exit 0. -- Parity: `hosted_memory_parity.rs` cap fixture: disk and memory admit and - defer the same rows; a memory rerun with pins (and with a committed +- Parity: `hosted_memory_parity.rs` single-root cap fixture: disk and + memory admit and defer the same rows. Two-root fixture: assert memory's + run-wide order and disk's per-directory order separately (they differ by + design, 5.2). A memory rerun with pins (and with a committed manifest / vendor state) lands the next N. - Parser contract rows for the flag and env var. @@ -982,7 +1006,11 @@ change), README (recipe R5, `--max-new-patches`), CHANGELOG - Resolve the `ScanArgs` struct-literal and `run_project_dirs` conflicts. - Combined e2e: a socket.yml with `includePaths`, `minSeverity: high` and `maxNewPatches: 2` over a two-root fixture, disk and memory, three runs to - convergence; `--no-socket-yml` drops the file's cap but keeps a flag cap. + convergence, asserting each engine's own budget scope (5.2); + `--no-socket-yml` drops the file's cap but keeps a flag cap. +- Switch `Candidate.project` to A's `repo_relative` and consume A's + `Offers` (before A lands, B uses canonical `--cwd` as the repo root and + treats the selected offers as the unfiltered list). - If B is ready before A merges, B ships with the file layer passed as `None` and wires it in a follow-up commit on its branch once A lands. From ff7a30ad7387871b676bffc39c3a0d2ddc26b5aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:58:03 +0000 Subject: [PATCH 04/14] Clarify who shapes scan's selection output The plan said both that the selector returns the shared offers struct (work item A) and that it returns admitted/deferred rows (work item B). The selector now returns the offers, and B adds the rollout stage after it. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/design/staged-rollout.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index c48967d8..dcaf65d5 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -948,10 +948,11 @@ Scope: offers. - Make `discover_selected` (`scan/mod.rs:553`) the single disk selection point: route the human agent/vendored arm (`mod.rs:2386-2402`) through - it, and have it return `{admitted, deferred}` so hosted + it, and add the step-7 stage after it (classify its `Offers`, planning + pass, `plan_rollout`) yielding `{admitted, deferred}`, so hosted (`run_redirect_selected`, `hosted.rs:1196`), vendored and agent writers receive only the rows 9.0 step 8 allows (ALREADY with the recorded - uuid). + uuid). `discover_selected`'s return type is A's `Offers`. - Classification from the merged recorded view (5.1); the planning pass for eligibility (hosted: grants, purl/url, vlt preflight, symlink refusals, rewriter planning; vendored: preflight; agent: partition); From 4fe9c6c5deb165a0cf54985b1e453a432520155d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:58:04 +0000 Subject: [PATCH 05/14] Add the socket.yml selection policy to core New socket_patch_core::policy module: the SelectionPolicy, Offers and repo-root helpers that the staged-rollout plan freezes as the shared contract between the socket.yml work and the --max-new-patches work. It reads the repo root's socket.yml/socket.yaml strictly and fails closed: bad YAML, a misspelled `patches` block, unknown keys (with a did-you-mean hint), wrong types, empty allowlists, bad globs and anchors or aliases inside the keys we read are all errors that name the key path. Path lists match marker files with npm `ignore` semantics, walked top-down; a golden fixture generated from the npm package pins that. The built-in test/fixture ignores become overridable defaults for discovered roots. --package matching moves to core so scan and the policy share it. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 127 +- Cargo.toml | 2 + .../socket-patch-cli/src/commands/scan/mod.rs | 38 +- crates/socket-patch-core/Cargo.toml | 2 + crates/socket-patch-core/src/lib.rs | 1 + crates/socket-patch-core/src/policy/mod.rs | 848 ++++++++++ crates/socket-patch-core/src/policy/paths.rs | 250 +++ .../src/policy/socket_yml.rs | 1195 ++++++++++++++ crates/socket-patch-core/src/policy/tests.rs | 587 +++++++ .../tests/fixtures/ignore_golden.json | 1379 +++++++++++++++++ scripts/gen-ignore-golden.mjs | 113 ++ 11 files changed, 4503 insertions(+), 39 deletions(-) create mode 100644 crates/socket-patch-core/src/policy/mod.rs create mode 100644 crates/socket-patch-core/src/policy/paths.rs create mode 100644 crates/socket-patch-core/src/policy/socket_yml.rs create mode 100644 crates/socket-patch-core/src/policy/tests.rs create mode 100644 crates/socket-patch-core/tests/fixtures/ignore_golden.json create mode 100644 scripts/gen-ignore-golden.mjs diff --git a/Cargo.lock b/Cargo.lock index 7b687168..eff61ddc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + [[package]] name = "anstream" version = "0.6.21" @@ -73,6 +84,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -122,6 +139,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -266,6 +293,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -404,6 +437,29 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -628,6 +684,29 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "granit-parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "h2" version = "0.4.14" @@ -896,6 +975,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1040,6 +1135,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "napi" version = "3.13.0" @@ -1390,9 +1491,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1589,6 +1690,20 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8" +dependencies = [ + "annotate-snippets", + "encoding_rs_io", + "granit-parser", + "num-traits", + "serde_core", + "smallvec", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1742,6 +1857,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1798,6 +1919,7 @@ dependencies = [ "fs2", "futures-util", "hex", + "ignore", "libc", "once_cell", "qbsdiff", @@ -1808,6 +1930,7 @@ dependencies = [ "self-replace", "semver", "serde", + "serde-saphyr", "serde_json", "serial_test", "sha1", diff --git a/Cargo.toml b/Cargo.toml index 6a95c787..2d241db0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,8 @@ serial_test = "=3.4.0" napi = { version = "=3.13.0", features = ["napi8", "tokio_rt"] } napi-derive = "=3.6.9" napi-build = "=2.5.0" +serde-saphyr = { version = "=1.3.0", default-features = false, features = ["deserialize"] } +ignore = "=0.4.33" [profile.release] strip = true diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index adaa869c..3bc5211b 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -374,43 +374,7 @@ pub struct ScanArgs { pub vex: VexEmbedArgs, } -/// Whether a `--package` spec names the package at `purl`: a purl spec -/// matches the same purl, or any version of it when it carries none; a -/// bare spec matches the package's full name (`@scope/pkg`, `group/name`) -/// or its last segment. Qualifiers are ignored and names compare -/// case-insensitively (PyPI, NuGet and Composer names are case-insensitive; -/// npm forbids uppercase). -pub(crate) fn package_spec_matches(spec: &str, purl: &str) -> bool { - let decoded = normalize_purl(strip_purl_qualifiers(purl)).to_lowercase(); - let spec = spec.trim().to_lowercase(); - if spec.is_empty() { - return false; - } - let Some(rest) = decoded.strip_prefix("pkg:") else { - return false; - }; - let Some((_eco, name_version)) = rest.split_once('/') else { - return false; - }; - let name = match name_version.rfind('@').filter(|&i| i > 0) { - Some(at) => &name_version[..at], - None => name_version, - }; - if let Some(spec_rest) = spec.strip_prefix("pkg:") { - let spec_purl = normalize_purl(strip_purl_qualifiers(&format!("pkg:{spec_rest}"))).to_lowercase(); - let spec_rest = &spec_purl[4..]; - let has_version = spec_rest - .split_once('/') - .is_some_and(|(_, nv)| nv.rfind('@').is_some_and(|i| i > 0)); - return if has_version { - decoded == spec_purl - } else { - decoded.strip_prefix(&spec_purl).is_some_and(|tail| tail.starts_with('@')) - }; - } - let spec = spec.replace(':', "/"); - name == spec || name.rsplit('/').next() == Some(spec.as_str()) -} +pub(crate) use socket_patch_core::policy::package_spec_matches; /// Embedded-VEX side-effect for `scan`'s JSON terminal returns. When /// `--vex` was requested and `base_code` is 0, generate the OpenVEX diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml index 3add95f0..ab6ed3af 100644 --- a/crates/socket-patch-core/Cargo.toml +++ b/crates/socket-patch-core/Cargo.toml @@ -42,6 +42,8 @@ tempfile = { workspace = true } zip = { workspace = true } base64 = { workspace = true } semver = { workspace = true } +serde-saphyr = { workspace = true } +ignore = { workspace = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index 04a2a6f9..ec1248d6 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod hash; pub mod manifest; pub mod package_json; pub mod patch; +pub mod policy; pub mod setup; pub mod telemetry; pub mod update; diff --git a/crates/socket-patch-core/src/policy/mod.rs b/crates/socket-patch-core/src/policy/mod.rs new file mode 100644 index 00000000..466d7f1c --- /dev/null +++ b/crates/socket-patch-core/src/policy/mod.rs @@ -0,0 +1,848 @@ +//! The repository's patch policy: the `patches` block and +//! `projectIgnorePaths` of the root `socket.yml`, plus the built-in default +//! path ignores. See `docs/design/staged-rollout.md` §3-§4. +//! +//! A policy only ever **narrows** what `scan` patches (trust boundary, +//! CLI_CONTRACT.md): nothing here names an endpoint, a credential, a mode +//! or a safety switch. Because it only narrows, an unreadable or invalid +//! file fails closed ([`PolicyError`]) instead of meaning "no policy". + +pub mod paths; +pub mod socket_yml; + +use std::collections::BTreeMap; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::api::ranking::max_severity_order; +use crate::api::types::PatchSearchResult; +use crate::crawlers::Ecosystem; +use crate::utils::purl::{normalize_purl, strip_purl_qualifiers}; + +use self::paths::{PathHit, PathMatcher}; +use self::socket_yml::{parse_file, ParsedFile, PatchesBlock}; + +pub use self::socket_yml::MAX_FILE_BYTES; + +/// Root file names, in the order they are read. +pub const POLICY_FILE_NAMES: [&str; 2] = ["socket.yml", "socket.yaml"]; + +/// Built-in ignores for discovered project roots (overridable with `!`). +pub const DEFAULT_IGNORE_PATHS: [&str; 5] = + ["test/", "tests/", "fixtures/", "__fixtures__/", "testdata/"]; + +/// List label of [`DEFAULT_IGNORE_PATHS`] in filter details. +pub const DEFAULT_IGNORE_LIST: &str = "built-in default"; + +pub const SOCKET_YML_INVALID: &str = "socket_yml_invalid"; +pub const SOCKET_YML_AMBIGUOUS: &str = "socket_yml_ambiguous"; +pub const SOCKET_YML_IGNORED_VALUE: &str = "socket_yml_ignored_value"; +pub const SOCKET_YML_NAME_CASE: &str = "socket_yml_name_case"; +pub const SOCKET_YML_REPO_UNTRUSTED: &str = "socket_yml_repo_untrusted"; +pub const PATCHES_DISABLED: &str = "patches_disabled"; +pub const POLICY_BYPASSED: &str = "policy_bypassed"; + +/// Longest file-derived string copied into output. +const MAX_OUTPUT_CHARS: usize = 200; + +/// Make a file-derived string safe to print: control characters dropped, +/// at most 200 characters. +pub fn sanitize(s: &str) -> String { + s.chars() + .filter(|c| !c.is_control()) + .take(MAX_OUTPUT_CHARS) + .collect() +} + +/// Where the policy came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicySource { + /// No file, an empty file, a file-less scan (`--global`), or only a + /// case variant of the name. + None, + /// A root file was read; `path` is its name relative to the repo root. + File { path: String, sha256: String }, + /// `--no-socket-yml` / `SOCKET_NO_SOCKET_YML`. + Bypassed, +} + +impl PolicySource { + pub fn as_str(&self) -> &'static str { + match self { + PolicySource::None => "none", + PolicySource::File { .. } => "file", + PolicySource::Bypassed => "bypassed", + } + } +} + +/// Why a root, package or patch was filtered. Codes are stable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FilterReason { + Disabled, + PathExcluded { + pattern: String, + list: &'static str, + }, + PathNotIncluded, + Ecosystem, + PackageNotListed, + PackageIgnored { + spec: String, + }, + Severity { + found: Option, + floor: String, + }, +} + +impl FilterReason { + pub fn code(&self) -> &'static str { + match self { + FilterReason::Disabled => "policy_disabled", + FilterReason::PathExcluded { .. } => "policy_path_excluded", + FilterReason::PathNotIncluded => "policy_path_not_included", + FilterReason::Ecosystem => "policy_ecosystem", + FilterReason::PackageNotListed => "policy_package_not_listed", + FilterReason::PackageIgnored { .. } => "policy_package_ignored", + FilterReason::Severity { .. } => "policy_severity", + } + } + + pub fn detail(&self) -> String { + match self { + FilterReason::Disabled => "patches.enabled is false".to_string(), + FilterReason::PathExcluded { pattern, list } => { + format!("{} ({list})", sanitize(pattern)) + } + FilterReason::PathNotIncluded => "not matched by patches.includePaths".to_string(), + FilterReason::Ecosystem => "ecosystem not in patches.ecosystems".to_string(), + FilterReason::PackageNotListed => "not in patches.packages".to_string(), + FilterReason::PackageIgnored { spec } => { + format!("{} (patches.ignorePackages)", sanitize(spec)) + } + FilterReason::Severity { found, floor } => { + format!("{} < {floor}", found.as_deref().unwrap_or("unknown")) + } + } + } +} + +/// A policy file that cannot be honored. Scan fails closed on it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyError { + Invalid { + file: String, + key: String, + message: String, + }, + Ambiguous { + files: [String; 2], + }, +} + +impl PolicyError { + pub fn code(&self) -> &'static str { + match self { + PolicyError::Invalid { .. } => SOCKET_YML_INVALID, + PolicyError::Ambiguous { .. } => SOCKET_YML_AMBIGUOUS, + } + } + + /// The message without the remedy. + pub fn detail(&self) -> String { + match self { + PolicyError::Invalid { file, key, message } if key.is_empty() => format!("{file}: {message}"), + PolicyError::Invalid { file, key, message } => format!("{file}: {key}: {message}"), + PolicyError::Ambiguous { files } => format!( + "{} and {} both exist and their `patches`/`projectIgnorePaths` differ; keep one file", + files[0], files[1] + ), + } + } +} + +impl std::fmt::Display for PolicyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} (fix the file, or pass --no-socket-yml to ignore it)", + self.detail() + ) + } +} + +impl std::error::Error for PolicyError {} + +/// A root file as the policy source sees it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RootFile { + Absent, + Present(Vec), + /// Listed, but its bytes were withheld (symlink, oversize, LFS pointer, + /// binary): never "absent", because the file may narrow the scan. + PresentWithoutContent, +} + +/// Read access to the repo root's policy files. +pub trait PolicyFs { + /// The root file named exactly `name`, read up to `cap + 1` bytes. + fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result; + + /// Root entries whose names equal a policy file name only ignoring case. + fn case_variants(&self) -> Vec { + Vec::new() + } +} + +/// [`PolicyFs`] over a directory on disk (the repo root). +pub struct DiskPolicyFs { + root: PathBuf, +} + +impl DiskPolicyFs { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + fn entry_names(&self) -> Vec { + std::fs::read_dir(&self.root) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect() + }) + .unwrap_or_default() + } +} + +#[cfg(unix)] +fn open_nonblocking(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + // O_NONBLOCK: opening a FIFO must not wait for a writer; the handle's + // metadata then refuses it. + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) +} + +#[cfg(not(unix))] +fn open_nonblocking(path: &Path) -> std::io::Result { + std::fs::File::open(path) +} + +fn io_other(message: &str) -> std::io::Error { + std::io::Error::other(message.to_string()) +} + +impl PolicyFs for DiskPolicyFs { + fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result { + if !self.entry_names().iter().any(|n| n == name) { + return Ok(RootFile::Absent); + } + let path = self.root.join(name); + let link_meta = std::fs::symlink_metadata(&path)?; + if link_meta.file_type().is_symlink() { + let target = std::fs::canonicalize(&path)?; + let root = std::fs::canonicalize(&self.root)?; + if !target.starts_with(&root) { + return Err(io_other("symlink resolves outside the repository root")); + } + } + let file = open_nonblocking(&path)?; + let meta = file.metadata()?; + if !meta.is_file() { + return Err(io_other("not a regular file")); + } + let mut bytes = Vec::new(); + file.take(cap as u64 + 1).read_to_end(&mut bytes)?; + if bytes.len() > cap { + return Err(io_other(&format!("larger than {} KiB", cap / 1024))); + } + Ok(RootFile::Present(bytes)) + } + + fn case_variants(&self) -> Vec { + let mut out: Vec = self + .entry_names() + .into_iter() + .filter(|n| { + POLICY_FILE_NAMES + .iter() + .any(|p| n.eq_ignore_ascii_case(p) && n != p) + }) + .collect(); + out.sort(); + out + } +} + +/// [`PolicyFs`] over an in-memory tree root (the hosted engine). +#[derive(Debug, Clone, Default)] +pub struct MemoryPolicyFs { + pub files: BTreeMap, + /// Every root entry name the tree lists, for the case-variant warning. + pub root_names: Vec, +} + +impl PolicyFs for MemoryPolicyFs { + fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result { + match self.files.get(name) { + None => Ok(RootFile::Absent), + Some(RootFile::Present(bytes)) if bytes.len() > cap => { + Err(io_other(&format!("larger than {} KiB", cap / 1024))) + } + Some(file) => Ok(file.clone()), + } + } + + fn case_variants(&self) -> Vec { + let mut out: Vec = self + .root_names + .iter() + .filter(|n| { + POLICY_FILE_NAMES + .iter() + .any(|p| n.eq_ignore_ascii_case(p) && n != p) + }) + .cloned() + .collect(); + out.sort(); + out.dedup(); + out + } +} + +/// Which layer set a scalar override. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverrideSource { + Flag, + Env, +} + +/// The invoking user's overrides (trusted; they beat the file). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PolicyOverrides { + /// Skip the file entirely (built-in default ignores still apply). + pub bypass: bool, + /// `(None, _)` is `none`: no floor, even when the file sets one. + pub min_severity: Option<(Option, OverrideSource)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyWarning { + pub code: &'static str, + pub detail: String, +} + +/// Where the effective severity floor came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeveritySource { + Flag, + Env, + File, + Default, +} + +impl SeveritySource { + pub fn as_str(&self) -> &'static str { + match self { + SeveritySource::Flag => "flag", + SeveritySource::Env => "env", + SeveritySource::File => "file", + SeveritySource::Default => "default", + } + } +} + +/// A project root, as the path filters see it. +#[derive(Debug, Clone, Copy)] +pub struct Root<'a> { + /// Repo-relative directory, `/` separators, `""` for the repo root. + pub rel_dir: &'a str, + /// The lockfile/manifest files the engine reads for this root, + /// relative to `rel_dir`. + pub markers: &'a [String], + /// Named by the user (never subject to the built-in default ignores). + pub explicit: bool, +} + +/// The effective selection policy of one invocation. +#[derive(Debug, Clone)] +pub struct SelectionPolicy { + source: PolicySource, + enabled: bool, + ignore_discovered: PathMatcher, + ignore_explicit: PathMatcher, + include: Option, + ecosystems: Option>, + packages: Option>, + ignore_packages: Vec, + min_severity: Option, + min_severity_source: SeveritySource, + max_new_patches: Option, +} + +/// Canonical severity name of an order (`moderate` reads as `medium`). +pub fn severity_name(order: u8) -> Option<&'static str> { + match order { + 0 => Some("critical"), + 1 => Some("high"), + 2 => Some("medium"), + 3 => Some("low"), + _ => None, + } +} + +/// Parse a `--min-severity` / `SOCKET_MIN_SEVERITY` value: a severity +/// name, or `none` for no floor. +pub fn parse_min_severity(value: &str) -> Result, String> { + if value.trim().eq_ignore_ascii_case("none") { + return Ok(None); + } + socket_yml::parse_severity_name(value) + .map(Some) + .ok_or_else(|| { + format!( + "invalid severity `{}`: expected critical, high, medium, moderate, low or none", + sanitize(value) + ) + }) +} + +/// The severity the floor judges a patch by: the worst severity across +/// the advisories it fixes (never `RankKey.severity`). +pub fn patch_severity_order(patch: &PatchSearchResult) -> u8 { + max_severity_order(patch.vulnerabilities.values().map(|v| v.severity.as_str())) +} + +fn defaults_list() -> Vec { + DEFAULT_IGNORE_PATHS.iter().map(|s| s.to_string()).collect() +} + +fn compile(lists: &[(&'static str, &[String])]) -> PathMatcher { + // Every list was compiled once during validation, so this cannot fail; + // an empty matcher would only ever admit more, which the validation + // already ruled out. + PathMatcher::new(lists) + .unwrap_or_else(|_| PathMatcher::new(&[]).expect("an empty pattern list always compiles")) +} + +impl SelectionPolicy { + /// No file: only the built-in default ignores. + pub fn unrestricted() -> Self { + Self::from_parts(PolicySource::None, &[], &PatchesBlock::default()) + } + + fn from_parts( + source: PolicySource, + project_ignore_paths: &[String], + block: &PatchesBlock, + ) -> Self { + let defaults = defaults_list(); + let ignore_discovered = compile(&[ + (DEFAULT_IGNORE_LIST, &defaults), + ("projectIgnorePaths", project_ignore_paths), + ("patches.ignorePaths", &block.ignore_paths), + ]); + let ignore_explicit = compile(&[ + ("projectIgnorePaths", project_ignore_paths), + ("patches.ignorePaths", &block.ignore_paths), + ]); + let include = block + .include_paths + .as_ref() + .map(|list| compile(&[("patches.includePaths", list)])); + Self { + source, + enabled: block.enabled.unwrap_or(true), + ignore_discovered, + ignore_explicit, + include, + ecosystems: block.ecosystems.clone(), + packages: block.packages.clone(), + ignore_packages: block.ignore_packages.clone(), + min_severity: block.min_severity, + min_severity_source: if block.min_severity.is_some() { + SeveritySource::File + } else { + SeveritySource::Default + }, + max_new_patches: block.max_new_patches, + } + } + + fn apply_overrides(&mut self, overrides: &PolicyOverrides) { + if let Some((value, source)) = overrides.min_severity { + self.min_severity = value; + self.min_severity_source = match source { + OverrideSource::Flag => SeveritySource::Flag, + OverrideSource::Env => SeveritySource::Env, + }; + } + } + + /// Read, validate and combine the repo root's policy files (4.4-4.5). + pub fn load( + fs: &dyn PolicyFs, + overrides: &PolicyOverrides, + ) -> Result<(Self, Vec), PolicyError> { + let mut warnings = Vec::new(); + if overrides.bypass { + let mut policy = + Self::from_parts(PolicySource::Bypassed, &[], &PatchesBlock::default()); + policy.apply_overrides(overrides); + return Ok((policy, warnings)); + } + for variant in fs.case_variants() { + warnings.push(PolicyWarning { + code: SOCKET_YML_NAME_CASE, + detail: format!( + "`{}` is not read: the policy file must be named exactly socket.yml or socket.yaml", + sanitize(&variant) + ), + }); + } + let mut files: Vec<(&'static str, Vec, ParsedFile)> = Vec::new(); + for name in POLICY_FILE_NAMES { + let invalid = |message: String| PolicyError::Invalid { + file: name.to_string(), + key: String::new(), + message, + }; + match fs.read_root_file(name, MAX_FILE_BYTES) { + Ok(RootFile::Absent) => {} + Ok(RootFile::PresentWithoutContent) => { + return Err(invalid( + "the file is listed but its content was not provided (symlink, oversize, LFS pointer or binary)" + .to_string(), + )) + } + Ok(RootFile::Present(bytes)) => { + if bytes.len() > MAX_FILE_BYTES { + return Err(invalid(format!("cannot read the file: larger than {} KiB", MAX_FILE_BYTES / 1024))); + } + let parsed = parse_file(name, &bytes, &mut warnings)?; + files.push((name, bytes, parsed)); + } + Err(e) => return Err(invalid(format!("cannot read the file: {e}"))), + } + } + if let [(first, _, a), (second, _, b)] = files.as_slice() { + if !a.same_policy(b) { + return Err(PolicyError::Ambiguous { + files: [first.to_string(), second.to_string()], + }); + } + } + let mut policy = match files.into_iter().next() { + Some((name, bytes, parsed)) if !parsed.empty => { + let source = PolicySource::File { + path: name.to_string(), + sha256: hex::encode(Sha256::digest(&bytes)), + }; + let block = parsed.patches.clone().unwrap_or_default(); + Self::from_parts(source, &parsed.project_ignore_paths, &block) + } + _ => Self::unrestricted(), + }; + policy.apply_overrides(overrides); + Ok((policy, warnings)) + } + + pub fn source(&self) -> &PolicySource { + &self.source + } + + /// `patches.enabled`. When false nothing is written (report only). + pub fn enabled(&self) -> bool { + self.enabled + } + + /// The effective severity floor and its source. + pub fn min_severity(&self) -> (Option, SeveritySource) { + (self.min_severity, self.min_severity_source) + } + + /// The file's `maxNewPatches`; `None` when there is no file, it was + /// bypassed, or the key is absent. + pub fn max_new_patches(&self) -> Option { + match self.source { + PolicySource::File { .. } => self.max_new_patches, + _ => None, + } + } + + /// Path filters for one project root (4.3): ignored iff every marker + /// is ignored; with `includePaths`, included iff any marker matches. + pub fn admits_root(&self, root: &Root) -> Result<(), FilterReason> { + let rel_dir = root.rel_dir.trim_matches('/'); + let subjects: Vec<(String, bool)> = if root.markers.is_empty() { + vec![(rel_dir.to_string(), true)] + } else { + root.markers + .iter() + .map(|m| { + let m = m.trim_start_matches('/'); + if rel_dir.is_empty() { + (m.to_string(), false) + } else { + (format!("{rel_dir}/{m}"), false) + } + }) + .collect() + }; + let ignore = if root.explicit { + &self.ignore_explicit + } else { + &self.ignore_discovered + }; + let mut first_hit: Option = None; + let mut all_ignored = true; + for (path, is_dir) in &subjects { + match ignore.check(path, *is_dir) { + Some(hit) => { + first_hit.get_or_insert(hit); + } + None => { + all_ignored = false; + break; + } + } + } + if all_ignored { + if let Some(hit) = first_hit { + return Err(FilterReason::PathExcluded { + pattern: sanitize(&hit.pattern), + list: hit.list, + }); + } + } + if let Some(include) = &self.include { + if !subjects + .iter() + .any(|(path, is_dir)| include.check(path, *is_dir).is_some()) + { + return Err(FilterReason::PathNotIncluded); + } + } + Ok(()) + } + + /// Ecosystem and package filters for one package (deny wins). + pub fn admits_purl(&self, purl: &str) -> Result<(), FilterReason> { + if let Some(ecosystems) = &self.ecosystems { + let eco = Ecosystem::from_purl(purl).map(|e| e.cli_name()); + if !eco.is_some_and(|e| ecosystems.iter().any(|x| x == e)) { + return Err(FilterReason::Ecosystem); + } + } + if let Some(spec) = self + .ignore_packages + .iter() + .find(|s| package_spec_matches(s, purl)) + { + return Err(FilterReason::PackageIgnored { + spec: sanitize(spec), + }); + } + if let Some(packages) = &self.packages { + if !packages.iter().any(|s| package_spec_matches(s, purl)) { + return Err(FilterReason::PackageNotListed); + } + } + Ok(()) + } + + /// The severity floor for one patch (`severity_order` ranks, unknown + /// = 4 is filtered whenever a floor is set). + pub fn admits_severity(&self, severity_order: u8) -> Result<(), FilterReason> { + let Some(floor) = self.min_severity else { + return Ok(()); + }; + if severity_order <= floor && severity_name(severity_order).is_some() { + return Ok(()); + } + Err(FilterReason::Severity { + found: severity_name(severity_order).map(str::to_string), + floor: severity_name(floor).unwrap_or("unknown").to_string(), + }) + } + + /// Split offers by the severity floor, keeping order. + pub fn floor_filter( + &self, + offers: Vec, + ) -> ( + Vec, + Vec<(PatchSearchResult, FilterReason)>, + ) { + let mut admitted = Vec::with_capacity(offers.len()); + let mut filtered = Vec::new(); + for offer in offers { + match self.admits_severity(patch_severity_order(&offer)) { + Ok(()) => admitted.push(offer), + Err(reason) => filtered.push((offer, reason)), + } + } + (admitted, filtered) + } +} + +/// The step 5 → 7 seam: every offer per purl (after the tier filter) and +/// the winner among the floor-admitted ones. +#[derive(Debug, Clone, Default)] +pub struct Offers { + pub unfiltered: BTreeMap>, + pub selected: BTreeMap, +} + +/// Whether a `--package` spec names the package at `purl`: a purl spec +/// matches the same purl, or any version of it when it carries none; a +/// bare spec matches the package's full name (`@scope/pkg`, `group/name`) +/// or its last segment. Qualifiers are ignored and names compare +/// case-insensitively (PyPI, NuGet and Composer names are case-insensitive; +/// npm forbids uppercase). +pub fn package_spec_matches(spec: &str, purl: &str) -> bool { + let decoded = normalize_purl(strip_purl_qualifiers(purl)).to_lowercase(); + let spec = spec.trim().to_lowercase(); + if spec.is_empty() { + return false; + } + let Some(rest) = decoded.strip_prefix("pkg:") else { + return false; + }; + let Some((_eco, name_version)) = rest.split_once('/') else { + return false; + }; + let name = match name_version.rfind('@').filter(|&i| i > 0) { + Some(at) => &name_version[..at], + None => name_version, + }; + if let Some(spec_rest) = spec.strip_prefix("pkg:") { + let spec_purl = + normalize_purl(strip_purl_qualifiers(&format!("pkg:{spec_rest}"))).to_lowercase(); + let spec_rest = &spec_purl[4..]; + let has_version = spec_rest + .split_once('/') + .is_some_and(|(_, nv)| nv.rfind('@').is_some_and(|i| i > 0)); + return if has_version { + decoded == spec_purl + } else { + decoded + .strip_prefix(&spec_purl) + .is_some_and(|tail| tail.starts_with('@')) + }; + } + let spec = spec.replace(':', "/"); + name == spec || name.rsplit('/').next() == Some(spec.as_str()) +} + +fn home_dir() -> Option { + let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + std::env::var_os(var) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .map(|p| std::fs::canonicalize(&p).unwrap_or(p)) +} + +fn ceiling_dirs() -> Vec { + std::env::var_os("GIT_CEILING_DIRECTORIES") + .map(|v| { + std::env::split_paths(&v) + .filter(|p| !p.as_os_str().is_empty()) + .map(|p| std::fs::canonicalize(&p).unwrap_or(p)) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(unix)] +fn trusted_owner(meta: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + // SAFETY: geteuid has no preconditions and cannot fail. + owner_trusted(meta.uid(), unsafe { libc::geteuid() }) +} + +#[cfg(unix)] +fn owner_trusted(owner: u32, euid: u32) -> bool { + owner == euid || owner == 0 +} + +#[cfg(not(unix))] +fn trusted_owner(_meta: &std::fs::Metadata) -> bool { + true +} + +/// The repo root for `cwd` (4.5) with the lookup's warnings: the nearest +/// ancestor (inclusive) holding a `.git` directory or file, not walking +/// past `GIT_CEILING_DIRECTORIES` or into the home directory, and (Unix) +/// only when `.git` belongs to the current user or root. Otherwise `cwd`. +pub fn find_repo_root_with_warnings(cwd: &Path) -> (PathBuf, Vec) { + let cwd = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); + let ceilings = ceiling_dirs(); + let home = home_dir(); + let mut warnings = Vec::new(); + let mut dir: &Path = &cwd; + loop { + if dir != cwd && home.as_deref() == Some(dir) { + break; + } + if let Ok(meta) = std::fs::symlink_metadata(dir.join(".git")) { + if meta.is_dir() || meta.is_file() { + if trusted_owner(&meta) { + return (dir.to_path_buf(), warnings); + } + warnings.push(PolicyWarning { + code: SOCKET_YML_REPO_UNTRUSTED, + detail: format!( + "{} is owned by another user; using {} as the repository root", + dir.join(".git").display(), + cwd.display() + ), + }); + break; + } + } + let Some(parent) = dir.parent() else { break }; + if ceilings.iter().any(|c| c == parent) { + break; + } + dir = parent; + } + (cwd.clone(), warnings) +} + +/// [`find_repo_root_with_warnings`] without the warnings. +pub fn find_repo_root(cwd: &Path) -> PathBuf { + find_repo_root_with_warnings(cwd).0 +} + +/// `dir` relative to `repo_root` with `/` separators (`""` for the root +/// itself); `None` when `dir` is not inside it. +pub fn repo_relative_checked(repo_root: &Path, dir: &Path) -> Option { + let rel = dir.strip_prefix(repo_root).ok()?; + let mut parts = Vec::new(); + for component in rel.components() { + match component { + std::path::Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()), + std::path::Component::CurDir => {} + _ => return None, + } + } + Some(parts.join("/")) +} + +/// [`repo_relative_checked`], falling back to `dir` itself (with `/` +/// separators) when it is outside the root. +pub fn repo_relative(repo_root: &Path, dir: &Path) -> String { + repo_relative_checked(repo_root, dir) + .unwrap_or_else(|| dir.to_string_lossy().replace('\\', "/")) +} + +#[cfg(test)] +mod tests; diff --git a/crates/socket-patch-core/src/policy/paths.rs b/crates/socket-patch-core/src/policy/paths.rs new file mode 100644 index 00000000..78219ae7 --- /dev/null +++ b/crates/socket-patch-core/src/policy/paths.rs @@ -0,0 +1,250 @@ +//! Path lists of the `socket.yml` patch policy: gitignore patterns matched +//! the way the npm `ignore` package (the backend's `projectIgnorePaths` +//! matcher) matches them. +//! +//! Evaluation walks top-down: for `a/b/c.lock` the matcher tests `a/`, +//! then `a/b/`, then the file, and the first ignored ancestor decides. A +//! negation can therefore never re-include anything under an ignored +//! directory, as in git and npm `ignore`. `ignore::gitignore`'s +//! `matched_path_or_any_parents` walks bottom-up and would re-include, so +//! it is not used. + +use std::path::PathBuf; + +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use ignore::Match; + +/// Longest accepted pattern, in bytes. +pub(crate) const MAX_PATTERN_BYTES: usize = 1024; + +/// Why a pattern is refused before it reaches the glob compiler. +pub(crate) fn pattern_hygiene_error(pattern: &str) -> Option { + if pattern.len() > MAX_PATTERN_BYTES { + return Some(format!("pattern is longer than {MAX_PATTERN_BYTES} bytes")); + } + if pattern.contains('\0') { + return Some("pattern contains a NUL byte".to_string()); + } + if pattern.trim().is_empty() { + return Some("pattern is empty".to_string()); + } + let body = pattern.strip_prefix('!').unwrap_or(pattern); + let body_no_slash = body.strip_prefix('/').unwrap_or(body); + let bytes = body_no_slash.as_bytes(); + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + return Some("pattern starts with a drive letter; paths are repo-relative".to_string()); + } + if body.split('/').any(|segment| segment == "..") { + return Some("pattern contains a `..` segment; paths are repo-relative".to_string()); + } + None +} + +/// One compiled, ordered pattern list (several named source lists joined; +/// the last matching pattern wins within a path). +#[derive(Clone, Debug)] +pub(crate) struct PathMatcher { + gitignore: Gitignore, + list_names: Vec<&'static str>, +} + +/// The pattern that decided a path, and the list it came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PathHit { + pub pattern: String, + pub list: &'static str, +} + +impl PathMatcher { + /// Compile `lists` in order. The error names the list and the pattern. + pub(crate) fn new( + lists: &[(&'static str, &[String])], + ) -> Result { + let mut builder = GitignoreBuilder::new(""); + // Never fails in ignore 0.4 (the Result is historical). + let _ = builder.case_insensitive(true); + builder.allow_unclosed_class(false); + let mut list_names = Vec::with_capacity(lists.len()); + for (name, patterns) in lists { + list_names.push(*name); + for pattern in patterns.iter() { + if let Some(message) = pattern_hygiene_error(pattern) { + return Err((name, pattern.clone(), message)); + } + if let Err(e) = builder.add_line(Some(PathBuf::from(*name)), pattern) { + return Err((name, pattern.clone(), format!("invalid pattern: {e}"))); + } + } + } + let gitignore = builder.build().map_err(|e| { + ( + lists.last().map_or("", |l| l.0), + String::new(), + e.to_string(), + ) + })?; + Ok(Self { + gitignore, + list_names, + }) + } + + fn hit(&self, glob: &ignore::gitignore::Glob) -> PathHit { + let list = glob + .from() + .and_then(|from| { + self.list_names + .iter() + .copied() + .find(|name| from == std::path::Path::new(name)) + }) + .unwrap_or(""); + PathHit { + pattern: glob.original().to_string(), + list, + } + } + + /// The pattern that matches the repo-relative `path` (`/` separators, + /// no leading `/`), walking its ancestors top-down; `None` when nothing + /// matches or a negation has the last word. + pub(crate) fn check(&self, path: &str, is_dir: bool) -> Option { + let path = path.trim_matches('/'); + if path.is_empty() || self.gitignore.is_empty() { + return None; + } + let segments: Vec<&str> = path.split('/').collect(); + for end in 1..segments.len() { + let ancestor = segments[..end].join("/"); + if let Match::Ignore(glob) = self.gitignore.matched(&ancestor, true) { + return Some(self.hit(glob)); + } + } + match self.gitignore.matched(path, is_dir) { + Match::Ignore(glob) => Some(self.hit(glob)), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn matcher(patterns: &[&str]) -> PathMatcher { + let owned: Vec = patterns.iter().map(|p| p.to_string()).collect(); + PathMatcher::new(&[("test", &owned)]).expect("patterns compile") + } + + #[derive(serde::Deserialize)] + struct GoldenCase { + patterns: Vec, + path: String, + ignored: bool, + } + + #[derive(serde::Deserialize)] + struct Golden { + generator: String, + cases: Vec, + } + + #[test] + fn agrees_with_npm_ignore_golden_fixture() { + let text = include_str!("../../tests/fixtures/ignore_golden.json"); + let golden: Golden = serde_json::from_str(text).expect("golden fixture parses"); + assert!(golden.generator.starts_with("ignore@")); + assert!(golden.cases.len() > 1000); + let mut mismatches = Vec::new(); + for case in &golden.cases { + let m = PathMatcher::new(&[("test", &case.patterns)]).expect("golden patterns compile"); + let got = m.check(&case.path, false).is_some(); + if got != case.ignored { + mismatches.push(format!( + "{:?} on {:?}: npm ignore says {}, we say {}", + case.patterns, case.path, case.ignored, got + )); + } + } + assert!( + mismatches.is_empty(), + "{} mismatches:\n{}", + mismatches.len(), + mismatches.join("\n") + ); + } + + #[test] + fn excluded_parent_cannot_be_reincluded() { + let m = matcher(&["fixtures/", "!/a/fixtures/keep/"]); + let hit = m + .check("a/fixtures/keep/yarn.lock", false) + .expect("still ignored"); + assert_eq!(hit.pattern, "fixtures/"); + assert_eq!(hit.list, "test"); + } + + #[test] + fn negation_of_the_directory_itself_reincludes() { + let m = matcher(&["tests/", "!/e2e/tests/"]); + assert!(m.check("e2e/tests/package-lock.json", false).is_none()); + assert!(m.check("x/tests/package-lock.json", false).is_some()); + } + + #[test] + fn case_insensitive_and_anchoring() { + let m = matcher(&["/legacy/"]); + assert!(m.check("Legacy/requirements.txt", false).is_some()); + assert!(m.check("x/legacy/requirements.txt", false).is_none()); + let bare = matcher(&["legacy/"]); + assert!(bare.check("x/legacy/requirements.txt", false).is_some()); + } + + #[test] + fn trailing_slash_only_matches_directories() { + let m = matcher(&["package-lock.json/"]); + assert!(m.check("package-lock.json", false).is_none()); + assert!(m.check("package-lock.json/x", false).is_some()); + } + + #[test] + fn reports_the_list_of_the_deciding_pattern() { + let a = vec!["tests/".to_string()]; + let b = vec!["/legacy/".to_string()]; + let m = PathMatcher::new(&[("defaults", &a), ("patches.ignorePaths", &b)]).unwrap(); + assert_eq!( + m.check("legacy/x.lock", false).unwrap().list, + "patches.ignorePaths" + ); + assert_eq!(m.check("a/tests/x.lock", false).unwrap().list, "defaults"); + } + + #[test] + fn hygiene_rejects_unsafe_patterns() { + for bad in [ + "../x", "a/../b", "!../x", "C:/x", "/c:/x", "a\0b", "", " ", + ] { + assert!( + pattern_hygiene_error(bad).is_some(), + "{bad:?} must be rejected" + ); + } + let long = "a".repeat(MAX_PATTERN_BYTES + 1); + assert!(pattern_hygiene_error(&long).is_some()); + for good in ["/a/", "..a/", "a..b/", "**/x", "!/e2e/tests/", "ab:c"] { + assert!( + pattern_hygiene_error(good).is_none(), + "{good:?} must be accepted" + ); + } + } + + #[test] + fn bad_glob_is_an_error_naming_the_pattern() { + let bad = vec!["a/[b".to_string()]; + let err = PathMatcher::new(&[("patches.ignorePaths", &bad)]) + .expect_err("unclosed class rejected"); + assert_eq!(err.0, "patches.ignorePaths"); + assert_eq!(err.1, "a/[b"); + } +} diff --git a/crates/socket-patch-core/src/policy/socket_yml.rs b/crates/socket-patch-core/src/policy/socket_yml.rs new file mode 100644 index 00000000..b434240d --- /dev/null +++ b/crates/socket-patch-core/src/policy/socket_yml.rs @@ -0,0 +1,1195 @@ +//! Parser and strict validator for the parts of `socket.yml` socket-patch +//! reads: `version`, `projectIgnorePaths` and the `patches` block. +//! +//! The file is read as a YAML 1.2 event stream (serde-saphyr's parser) +//! into a small node tree. Aliases are never expanded: an alias inside the +//! keys we read is refused, and one anywhere else is left alone, so an +//! alias bomb cannot cost anything. Plain scalars resolve with the YAML 1.2 +//! core schema (`no` is a string, `"false"` is not a bool). + +use std::collections::HashSet; + +use serde_saphyr::granit_parser::{Event, Options, Parser, ScalarStyle, Tag}; + +use super::paths::{PathMatcher, MAX_PATTERN_BYTES}; +use super::{sanitize, PolicyError, PolicyWarning}; +use crate::api::ranking::severity_order; +use crate::crawlers::Ecosystem; + +/// Largest accepted file, in bytes. +pub const MAX_FILE_BYTES: usize = 64 * 1024; +const MAX_DEPTH: usize = 32; +const MAX_LIST_ENTRIES: usize = 1000; + +pub(crate) const PATCHES_KEYS: [&str; 8] = [ + "enabled", + "includePaths", + "ignorePaths", + "ecosystems", + "packages", + "ignorePackages", + "minSeverity", + "maxNewPatches", +]; + +#[derive(Debug, Clone, PartialEq)] +enum Scalar { + Null, + Bool(bool), + Int(i128), + /// A float, or an integer too large for `i128`. + Number, + Str(String), +} + +#[derive(Debug)] +enum Kind { + Scalar { + value: Scalar, + raw: String, + plain: bool, + }, + Seq(Vec), + Map(Vec<(Node, Node)>), + Alias, +} + +#[derive(Debug)] +struct Node { + kind: Kind, + anchored: bool, + custom_tag: bool, +} + +impl Node { + fn as_str(&self) -> Option<&str> { + match &self.kind { + Kind::Scalar { + value: Scalar::Str(s), + .. + } => Some(s), + _ => None, + } + } + + fn is_merge_key(&self) -> bool { + matches!(&self.kind, Kind::Scalar { raw, plain: true, .. } if raw == "<<") + } + + fn describe(&self) -> &'static str { + match &self.kind { + Kind::Scalar { value, .. } => match value { + Scalar::Null => "null", + Scalar::Bool(_) => "a boolean", + Scalar::Int(_) | Scalar::Number => "a number", + Scalar::Str(_) => "a string", + }, + Kind::Seq(_) => "a list", + Kind::Map(_) => "a mapping", + Kind::Alias => "an alias", + } + } +} + +fn is_int_body(s: &str) -> bool { + !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) +} + +fn is_float(s: &str) -> bool { + let lower = s.to_ascii_lowercase(); + let unsigned = lower.trim_start_matches(['-', '+']); + if matches!(unsigned, ".inf") || lower == ".nan" { + return true; + } + let (mantissa, exponent) = match unsigned.split_once('e') { + Some((m, e)) => (m, Some(e)), + None => (unsigned, None), + }; + let mantissa_ok = match mantissa.split_once('.') { + Some((whole, frac)) => { + (whole.is_empty() || is_int_body(whole)) + && (frac.is_empty() || is_int_body(frac)) + && !(whole.is_empty() && frac.is_empty()) + } + None => is_int_body(mantissa), + }; + let exponent_ok = exponent.is_none_or(|e| is_int_body(e.trim_start_matches(['-', '+']))); + mantissa_ok && exponent_ok && (mantissa.contains('.') || exponent.is_some()) +} + +/// YAML 1.2 core-schema resolution of a plain scalar. +fn resolve_plain(raw: &str) -> Scalar { + match raw { + "" | "~" | "null" | "Null" | "NULL" => return Scalar::Null, + "true" | "True" | "TRUE" => return Scalar::Bool(true), + "false" | "False" | "FALSE" => return Scalar::Bool(false), + _ => {} + } + let (negative, unsigned) = match raw.as_bytes().first() { + Some(b'-') => (true, &raw[1..]), + Some(b'+') => (false, &raw[1..]), + _ => (false, raw), + }; + if is_int_body(unsigned) { + return match unsigned.parse::() { + Ok(n) => Scalar::Int(if negative { -n } else { n }), + Err(_) => Scalar::Number, + }; + } + if let Some(hex) = raw.strip_prefix("0x") { + if !hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return i128::from_str_radix(hex, 16).map_or(Scalar::Number, Scalar::Int); + } + } + if let Some(oct) = raw.strip_prefix("0o") { + if !oct.is_empty() && oct.bytes().all(|b| (b'0'..=b'7').contains(&b)) { + return i128::from_str_radix(oct, 8).map_or(Scalar::Number, Scalar::Int); + } + } + if is_float(raw) { + return Scalar::Number; + } + Scalar::Str(raw.to_string()) +} + +/// Resolve a scalar with its tag. `Err` is an explicit core tag the value +/// does not fit (`!!int abc`). +fn resolve_scalar( + raw: &str, + style: ScalarStyle, + tag: Option<&Tag>, +) -> Result<(Scalar, bool), String> { + let core = tag.and_then(|t| t.core_suffix().map(str::to_string)); + let custom = tag.is_some() && core.is_none(); + let plain = style == ScalarStyle::Plain; + let value = match core.as_deref() { + Some("str") => Scalar::Str(raw.to_string()), + Some(kind @ ("int" | "bool" | "null" | "float")) => { + let resolved = resolve_plain(raw); + let fits = matches!( + (kind, &resolved), + ("int", Scalar::Int(_)) + | ("bool", Scalar::Bool(_)) + | ("null", Scalar::Null) + | ("float", Scalar::Number | Scalar::Int(_)) + ); + if !fits { + return Err(format!("`{}` is not a valid !!{kind}", sanitize(raw))); + } + resolved + } + _ if plain && tag.is_none() => resolve_plain(raw), + _ => Scalar::Str(raw.to_string()), + }; + Ok((value, custom)) +} + +enum Frame { + Seq(Node, Vec), + Map(Node, Vec<(Node, Node)>, Option, HashSet), +} + +fn key_identity(key: &Node) -> Option { + match &key.kind { + Kind::Scalar { value, .. } => Some(format!("{value:?}")), + _ => None, + } +} + +/// Parse `text` into one document's root node; `None` for an empty or +/// comment-only stream. +fn build_tree(text: &str) -> Result, String> { + let mut options = Options::default(); + options.emit_comments = false; + let parser = Parser::new_from_str_with_options(text, options); + let mut stack: Vec = Vec::new(); + let mut root: Option = None; + let mut documents = 0usize; + + fn attach(stack: &mut [Frame], root: &mut Option, node: Node) -> Result<(), String> { + match stack.last_mut() { + None => { + *root = Some(node); + Ok(()) + } + Some(Frame::Seq(_, items)) => { + items.push(node); + Ok(()) + } + Some(Frame::Map(_, pairs, pending, seen)) => match pending.take() { + None => { + *pending = Some(node); + Ok(()) + } + Some(key) => { + if let Some(id) = key_identity(&key) { + if !seen.insert(id) { + let name = match &key.kind { + Kind::Scalar { raw, .. } => sanitize(raw), + _ => String::new(), + }; + return Err(format!("duplicate key `{name}`")); + } + } + pairs.push((key, node)); + Ok(()) + } + }, + } + } + + for event in parser { + let (event, span) = event.map_err(|e| e.to_string())?; + let line = span.start.line(); + let header = |anchor: usize, tag: Option<&Tag>| Node { + kind: Kind::Alias, + anchored: anchor != 0, + custom_tag: tag.is_some_and(|t| t.core_suffix().is_none()), + }; + match event { + Event::StreamStart | Event::StreamEnd | Event::DocumentEnd | Event::Comment(..) => {} + Event::DocumentStart(..) => { + documents += 1; + if documents > 1 { + return Err(format!("more than one YAML document (line {line})")); + } + } + Event::Alias(_) => { + let node = Node { + kind: Kind::Alias, + anchored: false, + custom_tag: false, + }; + attach(&mut stack, &mut root, node)?; + } + Event::Scalar(raw, style, anchor, tag) => { + let (value, custom_tag) = resolve_scalar(&raw, style, tag.as_deref()) + .map_err(|m| format!("{m} (line {line})"))?; + let node = Node { + kind: Kind::Scalar { + value, + raw: raw.into_owned(), + plain: style == ScalarStyle::Plain, + }, + anchored: anchor != 0, + custom_tag, + }; + attach(&mut stack, &mut root, node)?; + } + Event::SequenceStart(_, anchor, tag) => { + if stack.len() >= MAX_DEPTH { + return Err(format!( + "nesting is deeper than {MAX_DEPTH} levels (line {line})" + )); + } + stack.push(Frame::Seq(header(anchor, tag.as_deref()), Vec::new())); + } + Event::MappingStart(_, anchor, tag) => { + if stack.len() >= MAX_DEPTH { + return Err(format!( + "nesting is deeper than {MAX_DEPTH} levels (line {line})" + )); + } + stack.push(Frame::Map( + header(anchor, tag.as_deref()), + Vec::new(), + None, + HashSet::new(), + )); + } + Event::SequenceEnd => { + let Some(Frame::Seq(mut node, items)) = stack.pop() else { + return Err("unbalanced sequence".to_string()); + }; + node.kind = Kind::Seq(items); + attach(&mut stack, &mut root, node)?; + } + Event::MappingEnd => { + let Some(Frame::Map(mut node, pairs, _, _)) = stack.pop() else { + return Err("unbalanced mapping".to_string()); + }; + node.kind = Kind::Map(pairs); + attach(&mut stack, &mut root, node)?; + } + _ => return Err(format!("unsupported YAML construct (line {line})")), + } + } + Ok(root) +} + +/// The validated `patches` block. `None` list fields mean "absent". +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct PatchesBlock { + pub enabled: Option, + pub include_paths: Option>, + pub ignore_paths: Vec, + pub ecosystems: Option>, + pub packages: Option>, + pub ignore_packages: Vec, + pub min_severity: Option, + pub max_new_patches: Option, +} + +/// What one root file contributes. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct ParsedFile { + /// Empty, comment-only or null document: the file counts as absent. + pub empty: bool, + pub patches: Option, + pub project_ignore_paths: Vec, +} + +impl ParsedFile { + /// The parts two files must agree on when both exist. + pub(crate) fn same_policy(&self, other: &ParsedFile) -> bool { + self.patches == other.patches && self.project_ignore_paths == other.project_ignore_paths + } +} + +/// Levenshtein distance, for did-you-mean hints. +fn edit_distance(a: &str, b: &str) -> usize { + let b: Vec = b.chars().collect(); + let mut row: Vec = (0..=b.len()).collect(); + for (i, ca) in a.chars().enumerate() { + let mut prev = row[0]; + row[0] = i + 1; + for (j, cb) in b.iter().enumerate() { + let cur = row[j + 1]; + row[j + 1] = if ca == *cb { + prev + } else { + 1 + prev.min(cur).min(row[j]) + }; + prev = cur; + } + } + row[b.len()] +} + +fn did_you_mean<'a>(input: &str, known: impl IntoIterator) -> Option<&'a str> { + let lower = input.to_lowercase(); + known + .into_iter() + .map(|k| (edit_distance(&lower, &k.to_lowercase()), k)) + .filter(|(d, _)| *d <= 2) + .min_by_key(|(d, _)| *d) + .map(|(_, k)| k) +} + +struct Ctx<'a> { + file: &'a str, +} + +impl Ctx<'_> { + fn err(&self, key: impl Into, message: impl Into) -> PolicyError { + PolicyError::Invalid { + file: self.file.to_string(), + key: key.into(), + message: message.into(), + } + } +} + +/// Refuse anchors, aliases, merge keys and custom tags anywhere in `node`. +fn check_plain_subtree(node: &Node, path: &str) -> Result<(), (String, String)> { + if node.anchored { + return Err(( + path.to_string(), + "YAML anchors are not allowed here".to_string(), + )); + } + if node.custom_tag { + return Err(( + path.to_string(), + "custom YAML tags are not allowed here".to_string(), + )); + } + match &node.kind { + Kind::Alias => Err(( + path.to_string(), + "YAML aliases are not allowed here".to_string(), + )), + Kind::Scalar { .. } => Ok(()), + Kind::Seq(items) => { + for (i, item) in items.iter().enumerate() { + check_plain_subtree(item, &format!("{path}[{i}]"))?; + } + Ok(()) + } + Kind::Map(pairs) => { + for (key, value) in pairs { + if key.is_merge_key() { + return Err(( + path.to_string(), + "YAML merge keys (`<<`) are not allowed here".to_string(), + )); + } + let name = key.as_str().map(sanitize).unwrap_or_default(); + let child = if path.is_empty() { + name + } else { + format!("{path}.{name}") + }; + check_plain_subtree(key, &child)?; + check_plain_subtree(value, &child)?; + } + Ok(()) + } + } +} + +/// A list of strings, with the size limits every list key shares. +fn string_list(node: &Node, key: &str) -> Result, (String, String)> { + let Kind::Seq(items) = &node.kind else { + return Err(( + key.to_string(), + format!("must be a list of strings, found {}", node.describe()), + )); + }; + if items.len() > MAX_LIST_ENTRIES { + return Err(( + key.to_string(), + format!("has more than {MAX_LIST_ENTRIES} entries"), + )); + } + let mut out = Vec::with_capacity(items.len()); + for (i, item) in items.iter().enumerate() { + let path = format!("{key}[{i}]"); + let Some(s) = item.as_str() else { + return Err(( + path, + format!("must be a string, found {} (quote it)", item.describe()), + )); + }; + if s.len() > MAX_PATTERN_BYTES { + return Err((path, format!("is longer than {MAX_PATTERN_BYTES} bytes"))); + } + out.push(s.to_string()); + } + Ok(out) +} + +/// Compile a pattern list so a bad glob fails here, with its key path. +fn check_patterns(patterns: &[String], key: &'static str) -> Result<(), (String, String)> { + PathMatcher::new(&[(key, patterns)]) + .map(|_| ()) + .map_err(|(_, pattern, message)| { + let index = patterns.iter().position(|p| *p == pattern); + let path = index.map_or_else(|| key.to_string(), |i| format!("{key}[{i}]")); + (path, format!("{message}: `{}`", sanitize(&pattern))) + }) +} + +/// Why a `--package`-grammar spec is invalid, if it is. +pub(crate) fn package_spec_error(spec: &str) -> Option<&'static str> { + let spec = spec.trim(); + if spec.is_empty() { + return Some("package spec is empty"); + } + if spec.len() >= 4 && spec[..4].eq_ignore_ascii_case("pkg:") { + let rest = &spec[4..]; + let valid = rest.split_once('/').is_some_and(|(ty, name)| { + !ty.is_empty() && !name.trim_matches('/').is_empty() && !name.starts_with('@') + }); + if !valid { + return Some("purl spec needs a type and a name (`pkg:npm/lodash`)"); + } + } + None +} + +fn project_ignore_paths(node: &Node) -> Result, (String, String)> { + const KEY: &str = "projectIgnorePaths"; + check_plain_subtree(node, KEY)?; + let list = match &node.kind { + Kind::Scalar { + value: Scalar::Null, + .. + } => Vec::new(), + Kind::Scalar { + value: Scalar::Str(s), + .. + } => vec![s.clone()], + _ => string_list(node, KEY)?, + }; + if list.len() == 1 && list[0].len() > MAX_PATTERN_BYTES { + return Err(( + KEY.to_string(), + format!("is longer than {MAX_PATTERN_BYTES} bytes"), + )); + } + check_patterns(&list, KEY)?; + Ok(list) +} + +fn patches_block(node: &Node) -> Result { + let mut block = PatchesBlock::default(); + let pairs = match &node.kind { + Kind::Scalar { + value: Scalar::Null, + .. + } if !node.anchored && !node.custom_tag => return Ok(block), + Kind::Map(pairs) => pairs, + _ => { + check_plain_subtree(node, "patches")?; + return Err(( + "patches".to_string(), + format!("must be a mapping, found {}", node.describe()), + )); + } + }; + check_plain_subtree(node, "patches")?; + for (key, value) in pairs { + let Some(name) = key.as_str() else { + return Err(( + "patches".to_string(), + format!("keys must be strings, found {}", key.describe()), + )); + }; + let path = format!("patches.{}", sanitize(name)); + if !PATCHES_KEYS.contains(&name) { + let hint = did_you_mean(name, PATCHES_KEYS) + .map(|k| format!(" (did you mean `{k}`?)")) + .unwrap_or_default(); + return Err(( + path, + format!( + "unknown key{hint}; a newer socket-patch may support it: upgrade socket-patch or remove the key" + ), + )); + } + if matches!( + &value.kind, + Kind::Scalar { + value: Scalar::Null, + .. + } + ) { + return Err(( + path, + "has no value; remove the key to use the default".to_string(), + )); + } + match name { + "enabled" => match &value.kind { + Kind::Scalar { + value: Scalar::Bool(b), + .. + } => block.enabled = Some(*b), + _ => { + return Err(( + path, + format!("must be true or false, found {}", value.describe()), + )) + } + }, + "includePaths" | "ignorePaths" => { + let list = string_list(value, &path)?; + let key: &'static str = if name == "includePaths" { + "patches.includePaths" + } else { + "patches.ignorePaths" + }; + check_patterns(&list, key)?; + if name == "includePaths" { + if list.is_empty() { + return Err((path, "is empty and would match nothing; use `enabled: false` to pause patching".to_string())); + } + block.include_paths = Some(list); + } else { + block.ignore_paths = list; + } + } + "ecosystems" => { + let list = string_list(value, &path)?; + if list.is_empty() { + return Err(( + path, + "is empty and would match nothing; use `enabled: false` to pause patching" + .to_string(), + )); + } + let known: Vec<&str> = Ecosystem::all().iter().map(|e| e.cli_name()).collect(); + let mut out = Vec::with_capacity(list.len()); + for (i, entry) in list.iter().enumerate() { + let lower = entry.trim().to_lowercase(); + if !known.contains(&lower.as_str()) { + let hint = did_you_mean(&lower, known.iter().copied()) + .map(|k| format!(" (did you mean `{k}`?)")) + .unwrap_or_default(); + return Err(( + format!("{path}[{i}]"), + format!( + "unknown ecosystem `{}`{hint}; expected one of {}", + sanitize(entry), + known.join(", ") + ), + )); + } + out.push(lower); + } + block.ecosystems = Some(out); + } + "packages" | "ignorePackages" => { + let list = string_list(value, &path)?; + if name == "packages" && list.is_empty() { + return Err(( + path, + "is empty and would match nothing; use `enabled: false` to pause patching" + .to_string(), + )); + } + for (i, spec) in list.iter().enumerate() { + if let Some(message) = package_spec_error(spec) { + return Err(( + format!("{path}[{i}]"), + format!("{message}: `{}`", sanitize(spec)), + )); + } + } + if name == "packages" { + block.packages = Some(list); + } else { + block.ignore_packages = list; + } + } + "minSeverity" => { + let Some(s) = value.as_str() else { + return Err(( + path, + format!("must be a string, found {}", value.describe()), + )); + }; + match parse_severity_name(s) { + Some(order) => block.min_severity = Some(order), + None => { + return Err(( + path, + format!("unknown severity `{}`; expected critical, high, medium, moderate or low", sanitize(s)), + )) + } + } + } + "maxNewPatches" => match &value.kind { + Kind::Scalar { + value: Scalar::Int(n), + .. + } if (0..=i128::from(u32::MAX)).contains(n) => { + block.max_new_patches = Some(u32::try_from(*n).unwrap_or(u32::MAX)); + } + _ => { + return Err(( + path, + format!( + "must be an integer from 0 to {}, found {}", + u32::MAX, + value.describe() + ), + )) + } + }, + _ => unreachable!("PATCHES_KEYS is exhaustive"), + } + } + Ok(block) +} + +/// `critical|high|medium|moderate|low` (any case) to a severity order. +pub(crate) fn parse_severity_name(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + name @ ("critical" | "high" | "medium" | "moderate" | "low") => { + Some(severity_order(Some(name))) + } + _ => None, + } +} + +fn decode(ctx: &Ctx<'_>, bytes: &[u8]) -> Result { + if bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xFF, 0xFE]) { + return Err(ctx.err("", "file is UTF-16; save it as UTF-8")); + } + let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + if bytes.contains(&0) { + return Err(ctx.err("", "file contains NUL bytes; save it as UTF-8 text")); + } + String::from_utf8(bytes.to_vec()).map_err(|_| ctx.err("", "file is not valid UTF-8")) +} + +/// Parse and validate one root policy file (4.4). +pub(crate) fn parse_file( + file: &str, + bytes: &[u8], + warnings: &mut Vec, +) -> Result { + let ctx = Ctx { file }; + let text = decode(&ctx, bytes)?; + let root = build_tree(&text).map_err(|m| ctx.err("", format!("invalid YAML: {m}")))?; + let Some(root) = root else { + return Ok(ParsedFile { + empty: true, + ..ParsedFile::default() + }); + }; + let pairs = match &root.kind { + Kind::Map(pairs) => pairs, + Kind::Scalar { + value: Scalar::Null, + .. + } => { + return Ok(ParsedFile { + empty: true, + ..ParsedFile::default() + }) + } + _ => { + return Err(ctx.err( + "", + format!("the top level must be a mapping, found {}", root.describe()), + )); + } + }; + + let mut version: Option<&Node> = None; + let mut patches: Option<&Node> = None; + let mut ignore_paths: Option<&Node> = None; + for (key, value) in pairs { + let Some(name) = key.as_str() else { continue }; + let lower = name.to_ascii_lowercase(); + if (lower == "patch" || lower == "patches") && name != "patches" { + return Err(ctx.err( + sanitize(name), + "looks like a misspelled `patches` block; the key must be exactly `patches`", + )); + } + match name { + "version" => version = Some(value), + "patches" => patches = Some(value), + "projectIgnorePaths" => ignore_paths = Some(value), + _ => {} + } + } + + let Some(patches) = patches else { + let project_ignore_paths = match ignore_paths.map(project_ignore_paths) { + None => Vec::new(), + Some(Ok(list)) => list, + Some(Err((key, message))) => { + warnings.push(PolicyWarning { + code: super::SOCKET_YML_IGNORED_VALUE, + detail: format!("{file}: {key} {message}; the key is ignored"), + }); + Vec::new() + } + }; + return Ok(ParsedFile { + empty: false, + patches: None, + project_ignore_paths, + }); + }; + + let version_ok = version.is_some_and(|v| { + matches!( + &v.kind, + Kind::Scalar { + value: Scalar::Int(2), + .. + } + ) || matches!(&v.kind, Kind::Scalar { value: Scalar::Str(s), .. } if s == "2") + }); + if !version_ok { + return Err(ctx.err("version", "a `patches` block requires `version: 2`")); + } + let project_ignore_paths = match ignore_paths { + None => Vec::new(), + Some(node) => project_ignore_paths(node).map_err(|(key, message)| ctx.err(key, message))?, + }; + let block = patches_block(patches).map_err(|(key, message)| ctx.err(key, message))?; + Ok(ParsedFile { + empty: false, + patches: Some(block), + project_ignore_paths, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(text: &str) -> Result { + let mut warnings = Vec::new(); + parse_file("socket.yml", text.as_bytes(), &mut warnings) + } + + fn parse_warn(text: &str) -> (ParsedFile, Vec) { + let mut warnings = Vec::new(); + let parsed = parse_file("socket.yml", text.as_bytes(), &mut warnings).expect("parses"); + (parsed, warnings) + } + + fn err_key(text: &str) -> (String, String) { + match parse(text) { + Err(PolicyError::Invalid { key, message, .. }) => (key, message), + other => panic!("expected an invalid-file error for {text:?}, got {other:?}"), + } + } + + fn block(text: &str) -> PatchesBlock { + parse(text).expect("valid").patches.expect("patches block") + } + + #[test] + fn every_key_parses() { + let b = block( + "version: 2\npatches:\n enabled: false\n includePaths: [\"/services/\"]\n ignorePaths: [\"/legacy/\"]\n ecosystems: [NPM, pypi]\n packages: [\"pkg:npm/lodash\"]\n ignorePackages: [\"pkg:npm/left-pad\", \"core\"]\n minSeverity: High\n maxNewPatches: 5\n", + ); + assert_eq!(b.enabled, Some(false)); + assert_eq!(b.include_paths, Some(vec!["/services/".to_string()])); + assert_eq!(b.ignore_paths, vec!["/legacy/".to_string()]); + assert_eq!( + b.ecosystems, + Some(vec!["npm".to_string(), "pypi".to_string()]) + ); + assert_eq!(b.packages, Some(vec!["pkg:npm/lodash".to_string()])); + assert_eq!(b.ignore_packages.len(), 2); + assert_eq!(b.min_severity, Some(1)); + assert_eq!(b.max_new_patches, Some(5)); + } + + #[test] + fn defaults_when_absent() { + for text in [ + "version: 2\npatches:\n", + "version: 2\npatches: {}\n", + "version: 2\npatches: null\n", + ] { + assert_eq!(block(text), PatchesBlock::default(), "{text:?}"); + } + let parsed = parse("version: 2\n").unwrap(); + assert!(parsed.patches.is_none()); + assert!(!parsed.empty); + } + + #[test] + fn empty_and_comment_only_files_are_absent() { + for text in ["", "\n\n", "# just a comment\n", "---\n", "~\n"] { + assert!(parse(text).unwrap().empty, "{text:?}"); + } + } + + #[test] + fn bom_and_crlf_are_fine() { + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend_from_slice(b"version: 2\r\npatches:\r\n maxNewPatches: 3\r\n"); + let parsed = parse_file("socket.yml", &bytes, &mut Vec::new()).unwrap(); + assert_eq!(parsed.patches.unwrap().max_new_patches, Some(3)); + } + + #[test] + fn encoding_errors() { + for bytes in [ + &b"\xFF\xFEv\0e\0r\0"[..], + &b"\xFE\xFF\0v\0e"[..], + &b"version: 2\0\n"[..], + &b"version: \xC3\x28\n"[..], + ] { + assert!( + parse_file("socket.yml", bytes, &mut Vec::new()).is_err(), + "{bytes:?}" + ); + } + } + + #[test] + fn yaml_errors() { + for text in [ + "version: 2\npatches: [\n", + "a: 1\na: 2\n", + "version: 2\npatches:\n enabled: true\n enabled: false\n", + "- a\n- b\n", + "just a string\n", + "a: 1\n---\nb: 2\n", + "projectIgnorePaths:\n - **\n", + ] { + assert!(parse(text).is_err(), "{text:?} must fail"); + } + } + + #[test] + fn nesting_limit() { + let deep = format!("a: {}{}\n", "[".repeat(40), "]".repeat(40)); + let (key, message) = err_key(&deep); + assert_eq!(key, ""); + assert!(message.contains("deeper than 32"), "{message}"); + let ok = format!("a: {}{}\n", "[".repeat(30), "]".repeat(30)); + assert!(parse(&ok).is_ok()); + } + + #[test] + fn alias_bomb_elsewhere_is_never_expanded() { + let mut text = String::from( + "a: &a [\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\"]\n", + ); + let names = ["b", "c", "d", "e", "f", "g", "h", "i", "j"]; + let mut prev = "a"; + for name in names { + text.push_str(&format!("{name}: &{name} [*{prev},*{prev},*{prev},*{prev},*{prev},*{prev},*{prev},*{prev},*{prev}]\n")); + prev = name; + } + text.push_str("version: 2\npatches:\n maxNewPatches: 1\n"); + let started = std::time::Instant::now(); + assert_eq!(block(&text).max_new_patches, Some(1)); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + } + + #[test] + fn anchors_aliases_and_merge_keys_are_refused_in_our_keys() { + for text in [ + "version: 2\nx: &x [\"/a/\"]\npatches:\n ignorePaths: *x\n", + "version: 2\npatches:\n ignorePaths: &y [\"/a/\"]\n", + "version: 2\nbase: &b {maxNewPatches: 1}\npatches:\n <<: *b\n", + "version: 2\npatches:\n <<: {maxNewPatches: 1}\n", + "version: 2\nx: &x \"/a/\"\npatches: {}\nprojectIgnorePaths: [*x]\n", + "version: 2\npatches: &p {}\n", + "version: 2\npatches:\n minSeverity: !custom high\n", + ] { + let (_, message) = err_key(text); + assert!(message.contains("not allowed"), "{text:?}: {message}"); + } + // Outside the keys we read, anchors and aliases are someone else's business. + let ok = "version: 2\nissueRules: &r {a: 1}\nother: *r\npatches:\n maxNewPatches: 2\n"; + assert_eq!(block(ok).max_new_patches, Some(2)); + } + + #[test] + fn case_variant_of_patches_is_an_error() { + for text in [ + "version: 2\nPatches: {}\n", + "version: 2\npatch:\n enabled: false\n", + "PATCHES: {}\n", + ] { + let (_, message) = err_key(text); + assert!(message.contains("misspelled"), "{text:?}: {message}"); + } + } + + #[test] + fn version_gate() { + for text in [ + "patches: {}\n", + "version: 1\npatches: {}\n", + "version: 3\npatches: {}\n", + "version: 2.0\npatches: {}\n", + ] { + assert_eq!(err_key(text).0, "version", "{text:?}"); + } + assert!(parse("version: \"2\"\npatches: {}\n").is_ok()); + // No patches block: any version, projectIgnorePaths honored. + let parsed = parse("version: 1\nprojectIgnorePaths: [\"/a/\"]\n").unwrap(); + assert_eq!(parsed.project_ignore_paths, vec!["/a/".to_string()]); + } + + #[test] + fn unknown_keys_with_hint() { + let (key, message) = err_key("version: 2\npatches:\n minSeverty: high\n"); + assert_eq!(key, "patches.minSeverty"); + assert!(message.contains("did you mean `minSeverity`"), "{message}"); + assert!(message.contains("newer socket-patch"), "{message}"); + let (_, message) = err_key("version: 2\npatches:\n apiToken: x\n"); + assert!(!message.contains("did you mean"), "{message}"); + let (key, message) = err_key("version: 2\npatches:\n maxnewpatches: 1\n"); + assert_eq!(key, "patches.maxnewpatches"); + assert!( + message.contains("did you mean `maxNewPatches`"), + "{message}" + ); + } + + #[test] + fn trust_boundary_keys_are_unknown() { + for key in [ + "apiUrl", + "apiToken", + "org", + "mode", + "downloadMode", + "patchServerUrl", + "noVerify", + "strict", + ] { + let text = format!("version: 2\npatches:\n {key}: x\n"); + assert_eq!(err_key(&text).0, format!("patches.{key}")); + } + } + + #[test] + fn wrong_types() { + let cases = [ + ("enabled: \"false\"", "patches.enabled"), + ("enabled: no", "patches.enabled"), + ("enabled: 1", "patches.enabled"), + ("enabled:", "patches.enabled"), + ("includePaths: \"/a/\"", "patches.includePaths"), + ("includePaths: [1]", "patches.includePaths[0]"), + ("includePaths: []", "patches.includePaths"), + ("ecosystems: []", "patches.ecosystems"), + ("ecosystems: [npn]", "patches.ecosystems[0]"), + ("packages: []", "patches.packages"), + ("packages: [\"pkg:\"]", "patches.packages[0]"), + ( + "ignorePackages: [\"pkg:npm/\"]", + "patches.ignorePackages[0]", + ), + ("ignorePackages: [\" \"]", "patches.ignorePackages[0]"), + ("minSeverity: severe", "patches.minSeverity"), + ("minSeverity: none", "patches.minSeverity"), + ("minSeverity: 1", "patches.minSeverity"), + ("maxNewPatches: -1", "patches.maxNewPatches"), + ("maxNewPatches: 4294967296", "patches.maxNewPatches"), + ("maxNewPatches: 1.5", "patches.maxNewPatches"), + ("maxNewPatches: \"5\"", "patches.maxNewPatches"), + ("ignorePaths: [\"../x\"]", "patches.ignorePaths[0]"), + ("ignorePaths: [\"C:/x\"]", "patches.ignorePaths[0]"), + ("ignorePaths: [\"a/[b\"]", "patches.ignorePaths[0]"), + ("ignorePaths: {a: 1}", "patches.ignorePaths"), + ]; + for (line, key) in cases { + let text = format!("version: 2\npatches:\n {line}\n"); + assert_eq!(err_key(&text).0, key, "{line}"); + } + assert!(parse("version: 2\npatches: [a]\n").is_err()); + assert!(parse("version: 2\npatches: true\n").is_err()); + } + + #[test] + fn size_limits_on_lists() { + let many: Vec = (0..1001).map(|i| format!("\"/a{i}/\"")).collect(); + let text = format!( + "version: 2\npatches:\n ignorePaths: [{}]\n", + many.join(",") + ); + assert_eq!(err_key(&text).0, "patches.ignorePaths"); + let long = "a".repeat(1025); + let text = format!("version: 2\npatches:\n ignorePackages: [\"{long}\"]\n"); + assert_eq!(err_key(&text).0, "patches.ignorePackages[0]"); + } + + #[test] + fn integer_forms_and_boundaries() { + assert_eq!( + block("version: 2\npatches:\n maxNewPatches: 0\n").max_new_patches, + Some(0) + ); + assert_eq!( + block("version: 2\npatches:\n maxNewPatches: 4294967295\n").max_new_patches, + Some(u32::MAX) + ); + assert_eq!( + block("version: 2\npatches:\n maxNewPatches: 0x10\n").max_new_patches, + Some(16) + ); + assert_eq!( + block("version: 2\npatches:\n maxNewPatches: !!int \"7\"\n").max_new_patches, + Some(7) + ); + assert!(parse("version: 2\npatches:\n maxNewPatches: !!int seven\n").is_err()); + } + + #[test] + fn moderate_is_medium() { + assert_eq!( + block("version: 2\npatches:\n minSeverity: moderate\n").min_severity, + Some(2) + ); + assert_eq!( + block("version: 2\npatches:\n minSeverity: medium\n").min_severity, + Some(2) + ); + } + + #[test] + fn project_ignore_paths_rules() { + // Strict with a patches block. + let parsed = parse("version: 2\nprojectIgnorePaths: \"/a/\"\npatches: {}\n").unwrap(); + assert_eq!(parsed.project_ignore_paths, vec!["/a/".to_string()]); + assert_eq!( + err_key("version: 2\nprojectIgnorePaths: 5\npatches: {}\n").0, + "projectIgnorePaths" + ); + assert_eq!( + err_key("version: 2\nprojectIgnorePaths: [\"../x\"]\npatches: {}\n").0, + "projectIgnorePaths[0]" + ); + // Lenient without one: warning, key ignored. + let (parsed, warnings) = parse_warn("version: 2\nprojectIgnorePaths: {a: 1}\n"); + assert!(parsed.project_ignore_paths.is_empty()); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].code, "socket_yml_ignored_value"); + let (parsed, warnings) = parse_warn("projectIgnorePaths: [\"/a/\", \"b/\"]\n"); + assert_eq!(parsed.project_ignore_paths.len(), 2); + assert!(warnings.is_empty()); + } + + #[test] + fn yaml12_scalars() { + assert_eq!(resolve_plain("no"), Scalar::Str("no".to_string())); + assert_eq!(resolve_plain("yes"), Scalar::Str("yes".to_string())); + assert_eq!(resolve_plain("True"), Scalar::Bool(true)); + assert_eq!(resolve_plain("~"), Scalar::Null); + assert_eq!(resolve_plain("-12"), Scalar::Int(-12)); + assert_eq!(resolve_plain("0o17"), Scalar::Int(15)); + assert_eq!(resolve_plain("1e3"), Scalar::Number); + assert_eq!(resolve_plain(".5"), Scalar::Number); + assert_eq!(resolve_plain("1.2.3"), Scalar::Str("1.2.3".to_string())); + assert_eq!(resolve_plain("0x"), Scalar::Str("0x".to_string())); + } + + #[test] + fn same_policy_compares_parsed_values() { + let a = parse("version: 2\npatches: {maxNewPatches: 1}\n").unwrap(); + let b = parse( + "# other comments\nversion: \"2\"\npatches:\n maxNewPatches: 0x1\nissueRules: {}\n", + ) + .unwrap(); + assert!(a.same_policy(&b)); + let c = parse("version: 2\npatches: {maxNewPatches: 2}\n").unwrap(); + assert!(!a.same_policy(&c)); + let d = parse("version: 2\nprojectIgnorePaths: [\"/b/\", \"/a/\"]\n").unwrap(); + let e = parse("version: 2\nprojectIgnorePaths: [\"/a/\", \"/b/\"]\n").unwrap(); + assert!(!d.same_policy(&e), "order-sensitive"); + } + + #[test] + fn package_specs() { + for good in [ + "lodash", + "@babel/core", + "pkg:npm/lodash", + "pkg:npm/lodash@4.17.21", + "pkg:pypi/requests", + "PKG:npm/x", + ] { + assert!(package_spec_error(good).is_none(), "{good}"); + } + for bad in [ + "", + " ", + "pkg:", + "pkg:npm", + "pkg:npm/", + "pkg:/lodash", + "pkg:npm/@1.0.0", + ] { + assert!(package_spec_error(bad).is_some(), "{bad:?}"); + } + } + + #[test] + fn did_you_mean_distance() { + assert_eq!( + did_you_mean("ignorePath", PATCHES_KEYS), + Some("ignorePaths") + ); + assert_eq!(did_you_mean("zzzzzz", PATCHES_KEYS), None); + } +} diff --git a/crates/socket-patch-core/src/policy/tests.rs b/crates/socket-patch-core/src/policy/tests.rs new file mode 100644 index 00000000..8f053ffb --- /dev/null +++ b/crates/socket-patch-core/src/policy/tests.rs @@ -0,0 +1,587 @@ +use super::*; + +fn mem(files: &[(&str, &str)]) -> MemoryPolicyFs { + let mut fs = MemoryPolicyFs::default(); + for (name, text) in files { + fs.files.insert( + name.to_string(), + RootFile::Present(text.as_bytes().to_vec()), + ); + fs.root_names.push(name.to_string()); + } + fs +} + +fn load(files: &[(&str, &str)]) -> SelectionPolicy { + SelectionPolicy::load(&mem(files), &PolicyOverrides::default()) + .expect("valid policy") + .0 +} + +fn strings(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() +} + +fn root<'a>(rel_dir: &'a str, markers: &'a [String], explicit: bool) -> Root<'a> { + Root { + rel_dir, + markers, + explicit, + } +} + +#[test] +fn no_file_is_unrestricted_with_default_ignores() { + let (policy, warnings) = + SelectionPolicy::load(&MemoryPolicyFs::default(), &PolicyOverrides::default()).unwrap(); + assert_eq!(policy.source(), &PolicySource::None); + assert!(warnings.is_empty()); + assert!(policy.enabled()); + assert_eq!(policy.max_new_patches(), None); + let lock = strings(&["package-lock.json"]); + assert!(policy.admits_root(&root("", &lock, false)).is_ok()); + let err = policy + .admits_root(&root("packages/a/test", &lock, false)) + .unwrap_err(); + assert_eq!(err.code(), "policy_path_excluded"); + assert_eq!(err.detail(), "test/ (built-in default)"); + // Case-insensitive defaults, unlike the old hard-coded segment list. + assert!(policy + .admits_root(&root("Tests/app", &lock, false)) + .is_err()); + // Explicit roots never see the defaults. + assert!(policy + .admits_root(&root("packages/a/test", &lock, true)) + .is_ok()); +} + +#[test] +fn empty_file_is_source_none() { + let policy = load(&[("socket.yml", "# nothing\n")]); + assert_eq!(policy.source(), &PolicySource::None); +} + +#[test] +fn file_source_carries_path_and_hash() { + let text = "version: 2\npatches:\n maxNewPatches: 3\n"; + let policy = load(&[("socket.yml", text)]); + match policy.source() { + PolicySource::File { path, sha256 } => { + assert_eq!(path, "socket.yml"); + assert_eq!(sha256, &hex::encode(Sha256::digest(text.as_bytes()))); + } + other => panic!("{other:?}"), + } + assert_eq!(policy.max_new_patches(), Some(3)); + let yaml = load(&[("socket.yaml", text)]); + assert!(matches!(yaml.source(), PolicySource::File { path, .. } if path == "socket.yaml")); +} + +#[test] +fn bypass_ignores_the_file_but_keeps_defaults_and_flag_floor() { + let overrides = PolicyOverrides { + bypass: true, + min_severity: Some((Some(1), OverrideSource::Flag)), + }; + let fs = mem(&[( + "socket.yml", + "version: 2\npatches:\n enabled: false\n maxNewPatches: 1\n", + )]); + let (policy, _) = SelectionPolicy::load(&fs, &overrides).unwrap(); + assert_eq!(policy.source(), &PolicySource::Bypassed); + assert!(policy.enabled()); + assert_eq!(policy.max_new_patches(), None); + assert_eq!(policy.min_severity(), (Some(1), SeveritySource::Flag)); + let lock = strings(&["yarn.lock"]); + assert!(policy + .admits_root(&root("fixtures/x", &lock, false)) + .is_err()); + // An invalid file is not even read when bypassed. + let broken = mem(&[("socket.yml", "version: 2\npatches: [\n")]); + assert!(SelectionPolicy::load(&broken, &overrides).is_ok()); +} + +#[test] +fn severity_precedence_flag_env_file_default() { + let fs = mem(&[("socket.yml", "version: 2\npatches:\n minSeverity: high\n")]); + let (p, _) = SelectionPolicy::load(&fs, &PolicyOverrides::default()).unwrap(); + assert_eq!(p.min_severity(), (Some(1), SeveritySource::File)); + let env = PolicyOverrides { + bypass: false, + min_severity: Some((Some(0), OverrideSource::Env)), + }; + assert_eq!( + SelectionPolicy::load(&fs, &env).unwrap().0.min_severity(), + (Some(0), SeveritySource::Env) + ); + let none = PolicyOverrides { + bypass: false, + min_severity: Some((None, OverrideSource::Flag)), + }; + assert_eq!( + SelectionPolicy::load(&fs, &none).unwrap().0.min_severity(), + (None, SeveritySource::Flag) + ); + assert_eq!( + SelectionPolicy::unrestricted().min_severity(), + (None, SeveritySource::Default) + ); +} + +#[test] +fn severity_floor_filters_unknown_and_below() { + let policy = load(&[( + "socket.yml", + "version: 2\npatches:\n minSeverity: moderate\n", + )]); + assert!(policy.admits_severity(0).is_ok()); + assert!(policy.admits_severity(2).is_ok()); + let low = policy.admits_severity(3).unwrap_err(); + assert_eq!(low.code(), "policy_severity"); + assert_eq!(low.detail(), "low < medium"); + assert_eq!( + policy.admits_severity(4).unwrap_err().detail(), + "unknown < medium" + ); + let lowest = load(&[("socket.yml", "version: 2\npatches:\n minSeverity: low\n")]); + assert!(lowest.admits_severity(3).is_ok()); + assert!( + lowest.admits_severity(4).is_err(), + "low still drops unknown severity" + ); + assert!(SelectionPolicy::unrestricted().admits_severity(4).is_ok()); +} + +#[test] +fn floor_filter_uses_max_advisory_severity() { + use crate::api::types::VulnerabilityResponse; + use std::collections::HashMap; + let patch = |uuid: &str, severities: &[&str]| PatchSearchResult { + uuid: uuid.to_string(), + purl: "pkg:npm/a@1.0.0".to_string(), + published_at: String::new(), + description: String::new(), + license: String::new(), + tier: "free".to_string(), + vulnerabilities: severities + .iter() + .enumerate() + .map(|(i, s)| { + ( + format!("GHSA-{i}"), + VulnerabilityResponse { + cves: vec![], + summary: String::new(), + severity: s.to_string(), + description: String::new(), + }, + ) + }) + .collect::>(), + }; + let policy = load(&[("socket.yml", "version: 2\npatches:\n minSeverity: high\n")]); + let (kept, dropped) = policy.floor_filter(vec![ + patch("merged", &["LOW", "CRITICAL"]), + patch("low", &["LOW"]), + patch("none", &[]), + ]); + assert_eq!( + kept.iter().map(|p| p.uuid.as_str()).collect::>(), + ["merged"] + ); + assert_eq!(dropped.len(), 2); +} + +#[test] +fn both_files_equal_different_and_one_invalid() { + let a = "version: 2\npatches:\n maxNewPatches: 2\n"; + let same = "# different bytes, same policy\nversion: \"2\"\npatches: {maxNewPatches: 2}\n"; + let policy = load(&[("socket.yml", a), ("socket.yaml", same)]); + assert!(matches!(policy.source(), PolicySource::File { path, .. } if path == "socket.yml")); + + let other = "version: 2\npatches:\n maxNewPatches: 3\n"; + let err = SelectionPolicy::load( + &mem(&[("socket.yml", a), ("socket.yaml", other)]), + &PolicyOverrides::default(), + ) + .unwrap_err(); + assert_eq!(err.code(), "socket_yml_ambiguous"); + + let broken = "version: 2\npatches: [\n"; + let err = SelectionPolicy::load( + &mem(&[("socket.yml", a), ("socket.yaml", broken)]), + &PolicyOverrides::default(), + ) + .unwrap_err(); + assert_eq!(err.code(), "socket_yml_invalid"); + assert!(err.detail().starts_with("socket.yaml:"), "{}", err.detail()); +} + +#[test] +fn present_without_content_is_invalid_not_absent() { + let mut fs = MemoryPolicyFs::default(); + fs.files + .insert("socket.yml".to_string(), RootFile::PresentWithoutContent); + let err = SelectionPolicy::load(&fs, &PolicyOverrides::default()).unwrap_err(); + assert_eq!(err.code(), "socket_yml_invalid"); +} + +#[test] +fn oversize_memory_file_is_invalid() { + let mut fs = MemoryPolicyFs::default(); + fs.files.insert( + "socket.yml".to_string(), + RootFile::Present(vec![b' '; MAX_FILE_BYTES + 1]), + ); + assert!(SelectionPolicy::load(&fs, &PolicyOverrides::default()).is_err()); +} + +#[test] +fn case_variant_is_not_read_and_warns() { + let mut fs = mem(&[]); + fs.root_names.push("Socket.yml".to_string()); + let (policy, warnings) = SelectionPolicy::load(&fs, &PolicyOverrides::default()).unwrap(); + assert_eq!(policy.source(), &PolicySource::None); + assert_eq!(warnings[0].code, "socket_yml_name_case"); +} + +#[test] +fn error_display_names_file_key_and_remedy() { + let err = SelectionPolicy::load( + &mem(&[( + "socket.yml", + "version: 2\npatches:\n minSeverity: severe\n", + )]), + &PolicyOverrides::default(), + ) + .unwrap_err(); + let text = err.to_string(); + assert!( + text.starts_with("socket.yml: patches.minSeverity: unknown severity `severe`"), + "{text}" + ); + assert!(text.contains("--no-socket-yml"), "{text}"); +} + +#[test] +fn marker_rule_all_ignored_or_any_included() { + let policy = load(&[( + "socket.yml", + "version: 2\npatches:\n ignorePaths: [\"**/yarn.lock\"]\n includePaths: [\"/services/payments/\"]\n", + )]); + let both = strings(&["package.json", "yarn.lock"]); + let yarn = strings(&["yarn.lock"]); + // Not every marker ignored: the root stays. + assert!(policy + .admits_root(&root("services/payments", &both, false)) + .is_ok()); + assert_eq!( + policy + .admits_root(&root("services/payments", &yarn, false)) + .unwrap_err() + .code(), + "policy_path_excluded" + ); + assert_eq!( + policy + .admits_root(&root("services/api", &both, false)) + .unwrap_err(), + FilterReason::PathNotIncluded + ); + // The repo-root project alone: `/*` plus `!/*/`. + let only_root = load(&[( + "socket.yml", + "version: 2\npatches:\n includePaths: [\"/*\", \"!/*/\"]\n", + )]); + let lock = strings(&["package-lock.json"]); + assert!(only_root.admits_root(&root("", &lock, true)).is_ok()); + assert!(only_root.admits_root(&root("a", &lock, false)).is_err()); +} + +#[test] +fn deny_wins_over_include() { + let policy = load(&[( + "socket.yml", + "version: 2\npatches:\n includePaths: [\"/services/\"]\n ignorePaths: [\"/services/legacy/\"]\n", + )]); + let lock = strings(&["package-lock.json"]); + let err = policy + .admits_root(&root("services/legacy", &lock, true)) + .unwrap_err(); + assert_eq!(err.detail(), "/services/legacy/ (patches.ignorePaths)"); +} + +#[test] +fn defaults_negation_and_project_ignore_paths() { + let policy = load(&[( + "socket.yml", + "version: 2\nprojectIgnorePaths: [\"examples/**\"]\npatches:\n ignorePaths: [\"!/e2e/tests/\"]\n", + )]); + let lock = strings(&["package-lock.json"]); + assert!(policy.admits_root(&root("e2e/tests", &lock, false)).is_ok()); + assert!(policy + .admits_root(&root("other/tests", &lock, false)) + .is_err()); + let err = policy + .admits_root(&root("examples/demo", &lock, true)) + .unwrap_err(); + assert_eq!(err.detail(), "examples/** (projectIgnorePaths)"); + // An unrelated ignore never re-enables fixtures. + let unrelated = load(&[( + "socket.yml", + "version: 2\npatches:\n ignorePaths: [\"/legacy/\"]\n", + )]); + assert!(unrelated + .admits_root(&root("a/fixtures", &lock, false)) + .is_err()); + // projectIgnorePaths without a patches block is honored too. + let scanner_only = load(&[( + "socket.yml", + "version: 2\nprojectIgnorePaths:\n - \"crates/*/tests/fixtures/**\"\n", + )]); + let cargo = strings(&["Cargo.lock"]); + assert!(scanner_only + .admits_root(&root("crates/x/tests/fixtures/app", &cargo, true)) + .is_err()); +} + +#[test] +fn ecosystems_and_packages() { + let policy = load(&[( + "socket.yml", + "version: 2\npatches:\n ecosystems: [npm, deno]\n packages: [\"pkg:npm/lodash\", \"left-pad\", \"@std/path\"]\n ignorePackages: [\"pkg:npm/left-pad@1.0.0\"]\n", + )]); + assert!(policy.admits_purl("pkg:npm/lodash@4.17.20").is_ok()); + assert!(policy.admits_purl("pkg:jsr/@std/path@1.0.0").is_ok()); + assert_eq!( + policy.admits_purl("pkg:pypi/requests@2.0.0").unwrap_err(), + FilterReason::Ecosystem + ); + assert_eq!( + policy.admits_purl("pkg:npm/qs@6.5.2").unwrap_err(), + FilterReason::PackageNotListed + ); + let err = policy.admits_purl("pkg:npm/left-pad@1.0.0").unwrap_err(); + assert_eq!(err.code(), "policy_package_ignored"); + assert_eq!( + err.detail(), + "pkg:npm/left-pad@1.0.0 (patches.ignorePackages)" + ); + assert!(policy.admits_purl("pkg:npm/left-pad@1.1.0").is_ok()); + assert_eq!( + policy.admits_purl("pkg:unknown/x@1").unwrap_err(), + FilterReason::Ecosystem + ); +} + +#[test] +fn package_spec_matching_grammar() { + assert!(package_spec_matches("lodash", "pkg:npm/lodash@4.17.20")); + assert!(package_spec_matches("core", "pkg:npm/%40babel/core@7.0.0")); + assert!(package_spec_matches( + "@babel/core", + "pkg:npm/@babel/core@7.0.0" + )); + assert!(package_spec_matches( + "Requests", + "pkg:pypi/requests@2.0.0?artifact_id=x" + )); + assert!(package_spec_matches( + "pkg:npm/lodash", + "pkg:npm/lodash@1.0.0" + )); + assert!(!package_spec_matches( + "pkg:npm/lodash", + "pkg:npm/lodash-es@1.0.0" + )); + assert!(!package_spec_matches( + "pkg:npm/lodash@1.0.1", + "pkg:npm/lodash@1.0.0" + )); + assert!(!package_spec_matches("", "pkg:npm/lodash@1.0.0")); + assert!(!package_spec_matches("pkg:", "pkg:npm/lodash@1.0.0")); + assert!(package_spec_matches( + "org.example:lib", + "pkg:maven/org.example/lib@1.0" + )); +} + +#[test] +fn enabled_false_is_reported_by_callers() { + let policy = load(&[("socket.yml", "version: 2\npatches:\n enabled: false\n")]); + assert!(!policy.enabled()); + assert_eq!(FilterReason::Disabled.code(), "policy_disabled"); +} + +#[test] +fn min_severity_flag_values() { + assert_eq!(parse_min_severity("none"), Ok(None)); + assert_eq!(parse_min_severity("NONE"), Ok(None)); + assert_eq!(parse_min_severity("Critical"), Ok(Some(0))); + assert_eq!(parse_min_severity("moderate"), Ok(Some(2))); + assert!(parse_min_severity("severe").is_err()); + assert!(parse_min_severity("").is_err()); +} + +#[test] +fn sanitize_strips_controls_and_truncates() { + assert_eq!(sanitize("a\u{1b}[31mb\nc"), "a[31mbc"); + assert_eq!(sanitize(&"x".repeat(500)).chars().count(), 200); +} + +#[test] +fn repo_relative_paths() { + let root = Path::new("/r"); + assert_eq!(repo_relative(root, Path::new("/r")), ""); + assert_eq!(repo_relative(root, Path::new("/r/a/b")), "a/b"); + assert_eq!(repo_relative_checked(root, Path::new("/other")), None); +} + +mod disk { + use super::*; + use std::fs; + + fn read(dir: &Path, name: &str) -> std::io::Result { + DiskPolicyFs::new(dir).read_root_file(name, MAX_FILE_BYTES) + } + + #[test] + fn regular_file_absent_and_case_variant() { + let tmp = tempfile::tempdir().unwrap(); + assert_eq!(read(tmp.path(), "socket.yml").unwrap(), RootFile::Absent); + fs::write(tmp.path().join("Socket.yml"), "version: 2\n").unwrap(); + // Even on case-insensitive disks the exact name must be listed. + assert_eq!(read(tmp.path(), "socket.yml").unwrap(), RootFile::Absent); + assert_eq!( + DiskPolicyFs::new(tmp.path()).case_variants(), + vec!["Socket.yml".to_string()] + ); + fs::write(tmp.path().join("socket.yaml"), "version: 2\n").unwrap(); + assert_eq!( + read(tmp.path(), "socket.yaml").unwrap(), + RootFile::Present(b"version: 2\n".to_vec()) + ); + } + + #[test] + fn directory_and_oversize_are_errors() { + let tmp = tempfile::tempdir().unwrap(); + fs::create_dir(tmp.path().join("socket.yml")).unwrap(); + assert!(read(tmp.path(), "socket.yml").is_err()); + fs::write( + tmp.path().join("socket.yaml"), + vec![b'#'; MAX_FILE_BYTES + 1], + ) + .unwrap(); + assert!(read(tmp.path(), "socket.yaml").is_err()); + let err = + SelectionPolicy::load(&DiskPolicyFs::new(tmp.path()), &PolicyOverrides::default()) + .unwrap_err(); + assert_eq!(err.code(), "socket_yml_invalid"); + } + + #[test] + fn exactly_the_size_limit_is_fine() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("socket.yml"), vec![b'#'; MAX_FILE_BYTES]).unwrap(); + assert!( + matches!(read(tmp.path(), "socket.yml").unwrap(), RootFile::Present(b) if b.len() == MAX_FILE_BYTES) + ); + } + + #[cfg(unix)] + #[test] + fn symlink_inside_is_followed_outside_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + fs::create_dir_all(repo.join("config")).unwrap(); + fs::write(repo.join("config/policy.yml"), "version: 2\n").unwrap(); + std::os::unix::fs::symlink("config/policy.yml", repo.join("socket.yml")).unwrap(); + assert!(matches!( + read(&repo, "socket.yml").unwrap(), + RootFile::Present(_) + )); + + fs::write(tmp.path().join("outside.yml"), "version: 2\n").unwrap(); + std::os::unix::fs::symlink("../outside.yml", repo.join("socket.yaml")).unwrap(); + let err = read(&repo, "socket.yaml").unwrap_err(); + assert!(err.to_string().contains("outside"), "{err}"); + } + + #[cfg(unix)] + #[test] + fn fifo_is_refused_without_blocking() { + let tmp = tempfile::tempdir().unwrap(); + let fifo = tmp.path().join("socket.yml"); + let c = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap(); + // SAFETY: a valid NUL-terminated path. + assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0); + let err = read(tmp.path(), "socket.yml").unwrap_err(); + assert!(err.to_string().contains("regular file"), "{err}"); + } + + #[test] + fn repo_root_lookup_git_dir_git_file_and_none() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let repo = base.join("repo"); + fs::create_dir_all(repo.join(".git")).unwrap(); + fs::create_dir_all(repo.join("a/b")).unwrap(); + assert_eq!(find_repo_root(&repo.join("a/b")), repo); + assert_eq!(find_repo_root(&repo), repo); + + let worktree = base.join("wt"); + fs::create_dir_all(worktree.join("sub")).unwrap(); + fs::write(worktree.join(".git"), "gitdir: /elsewhere\n").unwrap(); + assert_eq!(find_repo_root(&worktree.join("sub")), worktree); + + let bare = base.join("plain/x"); + fs::create_dir_all(&bare).unwrap(); + assert_eq!(find_repo_root(&bare), bare); + } + + #[test] + #[serial_test::serial(git_ceiling_env)] + fn repo_root_lookup_honors_ceiling_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + fs::create_dir_all(base.join(".git")).unwrap(); + let cwd = base.join("ceiling/cwd"); + fs::create_dir_all(&cwd).unwrap(); + std::env::set_var("GIT_CEILING_DIRECTORIES", base.join("ceiling")); + let found = find_repo_root(&cwd); + std::env::remove_var("GIT_CEILING_DIRECTORIES"); + assert_eq!(found, cwd); + assert_eq!(find_repo_root(&cwd), base); + } + + #[cfg(unix)] + #[test] + fn owner_rule() { + assert!(owner_trusted(1000, 1000)); + assert!(owner_trusted(0, 1000)); + assert!(!owner_trusted(1001, 1000)); + } + + #[cfg(unix)] + #[test] + fn foreign_owned_git_stops_the_walk() { + // SAFETY: no preconditions. + if unsafe { libc::geteuid() } != 0 { + // Only root can hand `.git` to another owner; `owner_rule` + // covers the decision itself. + return; + } + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + fs::create_dir_all(base.join(".git")).unwrap(); + let cwd = base.join("sub"); + fs::create_dir_all(&cwd).unwrap(); + let git = std::ffi::CString::new(base.join(".git").to_str().unwrap()).unwrap(); + // SAFETY: a valid NUL-terminated path. + assert_eq!(unsafe { libc::chown(git.as_ptr(), 4242, 4242) }, 0); + let (found, warnings) = find_repo_root_with_warnings(&cwd); + assert_eq!(found, cwd); + assert_eq!(warnings[0].code, "socket_yml_repo_untrusted"); + } +} diff --git a/crates/socket-patch-core/tests/fixtures/ignore_golden.json b/crates/socket-patch-core/tests/fixtures/ignore_golden.json new file mode 100644 index 00000000..af975e19 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/ignore_golden.json @@ -0,0 +1,1379 @@ +{"generator": "ignore@7.0.10", "cases": [ + {"patterns":[],"path":"package-lock.json","ignored":false}, + {"patterns":[],"path":"Cargo.lock","ignored":false}, + {"patterns":[],"path":"a/package-lock.json","ignored":false}, + {"patterns":[],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":[],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":[],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":[],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":[],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":[],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":[],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":[],"path":"test/package-lock.json","ignored":false}, + {"patterns":[],"path":"Test/package-lock.json","ignored":false}, + {"patterns":[],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":[],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":[],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":[],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":[],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":[],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":[],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":[],"path":"examples/package-lock.json","ignored":false}, + {"patterns":[],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":[],"path":"testdata/go.sum","ignored":false}, + {"patterns":[],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":[],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":[],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":[],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":[],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"package-lock.json","ignored":true}, + {"patterns":["/package-lock.json"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/package-lock.json"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/package-lock.json"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["/package-lock.json"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/package-lock.json"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/package-lock.json"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/package-lock.json"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/package-lock.json"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/package-lock.json"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/package-lock.json"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/package-lock.json"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/package-lock.json"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["package-lock.json"],"path":"package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"Cargo.lock","ignored":false}, + {"patterns":["package-lock.json"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["package-lock.json"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["package-lock.json"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["package-lock.json"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["package-lock.json"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["package-lock.json"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["package-lock.json"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["package-lock.json"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["package-lock.json"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["package-lock.json"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["package-lock.json"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["package-lock.json"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"x[1]/package-lock.json","ignored":true}, + {"patterns":["**/yarn.lock"],"path":"package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"Cargo.lock","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["**/yarn.lock"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["**/yarn.lock"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["**/yarn.lock"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"Cargo.lock","ignored":false}, + {"patterns":["examples/**"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["examples/**"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["examples/**"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["examples/**"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["examples/**"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["examples/**"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["examples/**"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["examples/**"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["examples/**"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["examples/**"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["examples/**"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["examples/**"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["examples/**"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["examples/**"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["examples/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["examples/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["examples/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["examples/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["examples/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["examples/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["examples/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["examples/"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["examples/"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["examples/"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["examples/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["examples/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["examples/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["examples/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["examples/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/examples/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/examples/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["/examples/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/examples/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/examples/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/examples/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/examples/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/examples/"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["/examples/"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["/examples/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/examples/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/examples/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/examples/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/examples/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"Cargo.lock","ignored":false}, + {"patterns":["examples"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["examples"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["examples"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["examples"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["examples"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["examples"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["examples"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["examples"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["examples"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["examples"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["examples"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["examples"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["examples"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["examples"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["examples"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"Cargo.lock","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["crates/x/fixtures/**"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"Cargo.lock","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/legacy/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/legacy/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["/legacy/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["/legacy/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["/legacy/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/legacy/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/legacy/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/legacy/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/legacy/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/legacy/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/legacy/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/legacy/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["legacy/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["legacy/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["legacy/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["legacy/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["legacy/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["legacy/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["legacy/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["legacy/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["legacy/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["legacy/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["legacy/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["legacy/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/services/payments/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/services/payments/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["/services/payments/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":true}, + {"patterns":["/services/payments/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/services/payments/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/services/payments/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/services/payments/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/services/payments/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/services/payments/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/services/payments/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/services/payments/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/services/payments/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/services/*/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/services/*/"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["/services/*/"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["/services/*/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":true}, + {"patterns":["/services/*/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/services/*/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/services/*/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/services/*/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/services/*/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/services/*/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/services/*/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/services/*/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/services/*/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/services/*/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"Cargo.lock","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["services/**/package-lock.json"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["services/**/package-lock.json"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"package-lock.json","ignored":true}, + {"patterns":["/*","!/*/"],"path":"Cargo.lock","ignored":true}, + {"patterns":["/*","!/*/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/*","!/*/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["/*","!/*/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/*","!/*/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/*","!/*/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/*","!/*/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/*","!/*/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/*","!/*/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/*","!/*/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/*","!/*/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/*","!/*/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["*","!*/"],"path":"package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"Cargo.lock","ignored":true}, + {"patterns":["*","!*/"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["*","!*/"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":true}, + {"patterns":["*","!*/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["*","!*/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["*","!*/"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["*","!*/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["*","!*/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["*","!*/"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["*","!*/"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["*","!*/"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["*","!*/"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["*","!*/"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"x[1]/package-lock.json","ignored":true}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["a/","!a/b/"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["a/","!a/b/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["a/","!a/b/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["a/","!a/b/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["a/*","!a/b/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["a/*","!a/b/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["a/**","!a/b/**"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["a/**","!a/b/**"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["a/**","!a/b/**"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["a/**","!a/b/**"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["a/**/yarn.lock"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["a/**/yarn.lock"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"Cargo.lock","ignored":false}, + {"patterns":["**/b/**"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["**/b/**"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["**/b/**"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["**/b/**"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["**/b/**"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["**/b/**"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["**/b/**"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["**/b/**"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["**/b/**"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["**/b/**"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["**/b/**"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["**/b/**"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["**/b/**"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"Cargo.lock","ignored":true}, + {"patterns":["*.lock"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["*.lock"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["*.lock"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["*.lock"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["*.lock"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["*.lock"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["*.lock"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["*.lock"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["*.lock"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["*.lock"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["*.lock"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["*.lock"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"Cargo.lock","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["*.json","!package-lock.json"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"Cargo.lock","ignored":false}, + {"patterns":["!package-lock.json"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["!package-lock.json"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["!package-lock.json"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["!package-lock.json"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["!package-lock.json"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["!package-lock.json"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["!package-lock.json"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["!package-lock.json"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["!package-lock.json"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["!package-lock.json"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["!package-lock.json"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a/b"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["a/b"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["a/b"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a/b"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a/b"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a/b"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a/b"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a/b"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["a/b"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a/b"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a/b"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a/b"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["a/b"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/a/b/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["/a/b/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["/a/b/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["/a/b/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/a/b/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/a/b/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/a/b/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/a/b/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/a/b/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/a/b/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/a/b/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/a/b/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/a/b/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["b/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["b/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["b/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["b/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["b/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["b/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["b/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["b/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["b/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["b/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["b/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["b/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["b/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["**/c/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["**/c/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["**/c/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["**/c/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["**/c/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["**/c/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["**/c/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["**/c/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["**/c/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["**/c/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["**/c/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["**/c/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["LEGACY/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["LEGACY/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["LEGACY/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["LEGACY/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["LEGACY/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["LEGACY/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["LEGACY/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["LEGACY/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["LEGACY/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["LEGACY/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["LEGACY/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["Services/API/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["Services/API/"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["Services/API/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["Services/API/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["Services/API/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["Services/API/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["Services/API/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["Services/API/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["Services/API/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["Services/API/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["Services/API/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["Services/API/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["Services/API/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["foo bar/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["foo bar/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["foo bar/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["foo bar/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["foo bar/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["foo bar/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["foo bar/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["foo bar/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["foo bar/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["foo bar/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["foo bar/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["foo bar/"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["foo bar/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["foo\\ bar/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"x[1]/package-lock.json","ignored":true}, + {"patterns":["x[1]/"],"path":"package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["x[1]/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["x[1]/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["x[1]/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["x[1]/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["x[1]/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["x[1]/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["x[1]/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["x[1]/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["x[1]/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["x[1]/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["x[1]/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["x[1]/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["?.lock/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["?.lock/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["?.lock/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["?.lock/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["?.lock/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["?.lock/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["?.lock/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["?.lock/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["?.lock/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["?.lock/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["?.lock/"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["?.lock/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a.lock/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["a.lock/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a.lock/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a.lock/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a.lock/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a.lock/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a.lock/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["a.lock/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a.lock/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a.lock/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a.lock/"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["a.lock/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a/b/c/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["a/b/c/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a/b/c/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a/b/c/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a/b/c/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a/b/c/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a/b/c/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["a/b/c/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a/b/c/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a/b/c/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["a/b/c/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["**"],"path":"package-lock.json","ignored":true}, + {"patterns":["**"],"path":"Cargo.lock","ignored":true}, + {"patterns":["**"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["**"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":true}, + {"patterns":["**"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["**"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["**"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["**"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["**"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["**"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["**"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["**"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["**"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["**"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"x[1]/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"package-lock.json","ignored":false}, + {"patterns":["**/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["**/"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["**/"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":true}, + {"patterns":["**/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["**/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["**/"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["**/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["**/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["**/"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["**/"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["**/"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["**/"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["**/"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"x[1]/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"x[1]/package-lock.json","ignored":true}, + {"patterns":["services/"],"path":"package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["services/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["services/"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["services/"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["services/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":true}, + {"patterns":["services/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["services/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["services/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["services/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["services/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["services/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["services/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["services/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["services/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["services/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["#comment","legacy/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["#comment","legacy/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["\\#x","legacy/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["\\#x","legacy/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"Cargo.lock","ignored":false}, + {"patterns":["legacy/ "],"path":"a/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["legacy/ "],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["legacy/ "],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["legacy/ "],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["legacy/ "],"path":"test/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["legacy/ "],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["legacy/ "],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["legacy/ "],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"testdata/go.sum","ignored":false}, + {"patterns":["legacy/ "],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["legacy/ "],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["legacy/ "],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["legacy/ "],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"x[1]/package-lock.json","ignored":false} +]} diff --git a/scripts/gen-ignore-golden.mjs b/scripts/gen-ignore-golden.mjs new file mode 100644 index 00000000..35bedac3 --- /dev/null +++ b/scripts/gen-ignore-golden.mjs @@ -0,0 +1,113 @@ +// Regenerates crates/socket-patch-core/tests/fixtures/ignore_golden.json: +// the (patterns, path, ignored) table the socket.yml path matcher must +// agree with. The expected values come from the npm `ignore` package, the +// matcher the Socket backend applies to `projectIgnorePaths`. +// +// cd "$(mktemp -d)" && npm init -y >/dev/null && npm i ignore@7.0.10 \ +// && NODE_PATH="$PWD/node_modules" node /path/to/scripts/gen-ignore-golden.mjs +import { createRequire } from 'node:module' +import { writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const require = createRequire(join(process.env.NODE_PATH ?? '.', 'x.js')) +const ignore = require('ignore') +const version = require('ignore/package.json').version + +const paths = [ + 'package-lock.json', + 'Cargo.lock', + 'a/package-lock.json', + 'a/b/package-lock.json', + 'a/b/c/yarn.lock', + 'services/api/package-lock.json', + 'services/payments/package-lock.json', + 'services/payments/sub/pnpm-lock.yaml', + 'legacy/requirements.txt', + 'Legacy/requirements.txt', + 'test/package-lock.json', + 'Test/package-lock.json', + 'tests/fixtures/app/package-lock.json', + 'e2e/tests/package-lock.json', + 'e2e/tests/keep/package-lock.json', + 'crates/x/tests/fixtures/app/Cargo.lock', + 'crates/x/fixtures/keep/Cargo.lock', + 'a/fixtures/keep/yarn.lock', + 'examples/demo/package-lock.json', + 'examples/package-lock.json', + 'docs/examples/package-lock.json', + 'testdata/go.sum', + 'pkg/testdata/go.sum', + '__fixtures__/x/package.json', + 'a.lock/yarn.lock', + 'foo bar/package-lock.json', + 'x[1]/package-lock.json', +] + +const sets = [ + [], + ['/package-lock.json'], + ['package-lock.json'], + ['**/yarn.lock'], + ['examples/**'], + ['examples/'], + ['/examples/'], + ['examples'], + ['crates/x/fixtures/**'], + ['crates/*/tests/fixtures/**'], + ['/legacy/'], + ['legacy/'], + ['/services/payments/'], + ['/services/*/'], + ['services/**/package-lock.json'], + ['/*', '!/*/'], + ['*', '!*/'], + ['fixtures/', '!/a/fixtures/keep/'], + ['fixtures/', '!fixtures/'], + ['test/', 'tests/', 'fixtures/', '__fixtures__/', 'testdata/'], + ['test/', 'tests/', 'fixtures/', '__fixtures__/', 'testdata/', '!/e2e/tests/'], + ['test/', 'tests/', 'fixtures/', '__fixtures__/', 'testdata/', '!tests/'], + ['test/', 'tests/', 'fixtures/', '__fixtures__/', 'testdata/', '/legacy/'], + ['a/', '!a/b/'], + ['a/*', '!a/b/'], + ['a/**', '!a/b/**'], + ['a/**/yarn.lock'], + ['**/b/**'], + ['*.lock'], + ['*.json', '!package-lock.json'], + ['!package-lock.json'], + ['a/b'], + ['/a/b/'], + ['b/'], + ['**/c/'], + ['LEGACY/'], + ['Services/API/'], + ['foo bar/'], + ['foo\\ bar/'], + ['x\\[1\\]/'], + ['x[1]/'], + ['?.lock/'], + ['a.lock/'], + ['a/b/c/'], + ['**'], + ['**/'], + ['/**/package-lock.json'], + ['services/'], + ['#comment', 'legacy/'], + ['\\#x', 'legacy/'], + ['legacy/ '], +] + +const cases = [] +for (const patterns of sets) { + const ig = ignore().add(patterns) + for (const path of paths) { + cases.push({ patterns, path, ignored: ig.ignores(path) }) + } +} + +const here = dirname(fileURLToPath(import.meta.url)) +const out = join(here, '..', 'crates', 'socket-patch-core', 'tests', 'fixtures', 'ignore_golden.json') +const body = cases.map((c) => ' ' + JSON.stringify(c)).join(',\n') +writeFileSync(out, `{"generator": "ignore@${version}", "cases": [\n${body}\n]}\n`) +console.log(`wrote ${cases.length} cases to ${out}`) From f5db6be03d0523b848479953df1488c7e7cf74a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:16:02 +0000 Subject: [PATCH 06/14] Honor socket.yml patch policy in disk scans scan now reads the repo root's socket.yml before any write and applies its patches block in hosted, vendored and agent mode, --dry-run included: path filters on project roots (PATH-glob matches also get the built-in test/fixture ignores), ecosystem and package filters on crawled packages, and a severity floor on the patches a package may receive. An invalid or ambiguous file fails the run with exit 1 and an errorCode before any request. Packages that already carry a patch are never removed, upgraded or replaced by the policy: they are held and reported under policy.retained. A recorded patch below a new floor stays in place. patches.enabled: false reports what would be patched and writes nothing. New flags: --no-socket-yml / SOCKET_NO_SOCKET_YML and --min-severity / SOCKET_MIN_SEVERITY. Every successful scan --json result gains a top-level policy block. A PATH outside the repository root is a usage error. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/hosted.rs | 7 +- .../socket-patch-cli/src/commands/scan/mod.rs | 293 ++++--- .../src/commands/scan/policy.rs | 482 ++++++++++ .../src/commands/scan/socket_yml_args.rs | 58 ++ .../src/commands/scan/vendor_flow.rs | 9 +- .../socket-patch-cli/tests/cli_parse_scan.rs | 12 + .../tests/e2e_socket_yml_policy.rs | 821 ++++++++++++++++++ .../tests/in_process_cargo_apply.rs | 3 + .../tests/in_process_gem_apply.rs | 2 + .../tests/in_process_gem_multi_platform.rs | 1 + .../tests/in_process_pypi_apply.rs | 4 + .../tests/in_process_pypi_multi_release.rs | 1 + .../tests/in_process_python_envs.rs | 1 + .../tests/in_process_redirect.rs | 1 + .../tests/in_process_redirect_pdm.rs | 1 + .../tests/in_process_redirect_pipenv.rs | 1 + .../tests/in_process_redirect_pnpm.rs | 1 + .../tests/in_process_redirect_poetry.rs | 1 + .../in_process_remote_ecosystems_apply.rs | 1 + .../tests/in_process_rollback_hosted.rs | 1 + .../socket-patch-cli/tests/in_process_scan.rs | 1 + .../tests/in_process_vendor.rs | 1 + 22 files changed, 1593 insertions(+), 110 deletions(-) create mode 100644 crates/socket-patch-cli/src/commands/scan/policy.rs create mode 100644 crates/socket-patch-cli/src/commands/scan/socket_yml_args.rs create mode 100644 crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 6231254a..d8426e9f 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1046,6 +1046,7 @@ pub(super) async fn run_redirect( api_client: &socket_patch_core::api::client::ApiClient, all_packages_with_patches: &[BatchPackagePatches], can_access_paid_patches: bool, + policy: &super::policy::ScanPolicy, // The classic scan object `run` builds for the `--json` path (`Some` in // JSON mode, `None` for human output). The redirect result is NESTED into // it so the hosted `--json` envelope stays schema-consistent with every @@ -1060,11 +1061,11 @@ pub(super) async fn run_redirect( npm_prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>, ) -> i32 { // Same discovery/selection as `--apply`/`--vendor`. - let selected = match discover_selected( + let selected: Vec = match discover_selected( api_client, all_packages_with_patches, can_access_paid_patches, - &args.common, + policy, false, false, telemetry, @@ -1072,7 +1073,7 @@ pub(super) async fn run_redirect( ) .await { - Ok(s) => s, + Ok(offers) => offers.selected.into_values().collect(), // Hosted mode has no discovery envelope to fold the message into at // this point (it builds its `redirect` result further down). // `discover_selected` already printed the message to stderr; a diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 3bc5211b..aecfacda 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -33,11 +33,16 @@ use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::ecosystem_dispatch::{crawl_ecosystems, crawl_ecosystems_with_npm}; use crate::ui::{self, plural, print_json, StatusLine}; -use super::get::{download_and_apply_patches_with, select_patches, DownloadParams, DownloadRun}; +use super::get::{download_and_apply_patches_with, DownloadParams, DownloadRun}; + +pub use self::socket_yml_args::{SocketYmlArgs, MIN_SEVERITY_ENV}; +use self::policy::{load_invocation_policy, InvocationPolicy, PolicyLoadError, ScanPolicy}; mod discovery; mod gc; pub(crate) mod hosted; +pub(crate) mod policy; +mod socket_yml_args; pub(crate) mod render; pub(crate) mod vendor_flow; @@ -372,6 +377,9 @@ pub struct ScanArgs { /// VEX makes the command exit non-zero. #[command(flatten)] pub vex: VexEmbedArgs, + + #[command(flatten)] + pub socket_yml: SocketYmlArgs, } pub(crate) use socket_patch_core::policy::package_spec_matches; @@ -504,9 +512,9 @@ async fn embed_vex_human( /// resolve the top-ranked accessible patch per PURL. Per-package search /// errors are skipped, but when EVERY query errors the empty set would be /// indistinguishable from a genuine "no patches" result, so that surfaces -/// as `Err(1)` with the failure on stderr. Selects with [`selection_args`]: -/// scan never prompts, so every run auto-selects the top-ranked patch (see -/// `api::ranking`) rather than erroring with `selection_required`. `Err` +/// as `Err(1)` with the failure on stderr. Selects with +/// [`select_accessible`]: scan never prompts, so every run auto-selects the +/// top-ranked patch the policy admits (see `api::ranking`). `Err` /// carries the exit code AND the message, since JSON callers must fold it /// into their single envelope (CLI_CONTRACT.md). `show_progress` / `warn` /// are the human-only knobs of [`fetch_patch_details`] (JSON callers pass @@ -518,12 +526,12 @@ async fn discover_selected( api_client: &socket_patch_core::api::client::ApiClient, packages: &[BatchPackagePatches], can_access_paid_patches: bool, - common: &GlobalArgs, + policy: &ScanPolicy, show_progress: bool, warn: bool, telemetry: &mut PendingTelemetry, json_warnings: Option<&mut serde_json::Value>, -) -> Result, (i32, String)> { +) -> Result { let (all_search_results, failures) = fetch_patch_details(api_client, packages, show_progress, warn).await; // The scan event's send overlapped the detail fetches; every caller's @@ -543,6 +551,7 @@ async fn discover_selected( // Some queries failed, some succeeded: a `--json` run has no stderr // warning (`warn` is human-only), so each failed package becomes a // run-level `warnings[]` entry — never a silent drop from the envelope. + let offers = select_accessible(all_search_results, can_access_paid_patches, policy); if let Some(result) = json_warnings { for (purl, e) in &failures { push_scan_json_warning( @@ -551,37 +560,24 @@ async fn discover_selected( &format!("could not fetch details for {purl}: {e}"), ); } + policy.fold_into_json(result); } - if all_search_results.is_empty() { - return Ok(Vec::new()); - } - if common.json { - // Pre-filter to accessible patches so `select_patches` takes the - // top-ranked one per PURL. - let accessible: Vec = all_search_results - .into_iter() - .filter(|p| can_access_paid_patches || p.tier == "free") - .collect(); - return select_patches(&accessible, true, &selection_args(common)) - .map_err(|code| (code, "patch selection failed".to_string())); - } - select_patches( - &all_search_results, - can_access_paid_patches, - &selection_args(common), - ) - .map_err(|code| (code, "patch selection failed".to_string())) + Ok(offers) } -/// `common` for `select_patches`: scan never prompts, so it always takes -/// the top-ranked patch, and with `json` off it never gets -/// `selection_required` (scan has no "re-run with the chosen UUID" path). -fn selection_args(common: &GlobalArgs) -> GlobalArgs { - GlobalArgs { - json: false, - yes: true, - ..common.clone() - } +/// The tier filter, then the policy's per-package selection (see +/// [`ScanPolicy::select`]): scan never prompts, so every package gets its +/// top-ranked admitted patch (see `api::ranking`). +fn select_accessible( + all_search_results: Vec, + can_access_paid_patches: bool, + policy: &ScanPolicy, +) -> socket_patch_core::policy::Offers { + let accessible: Vec = all_search_results + .into_iter() + .filter(|p| can_access_paid_patches || p.tier == "free") + .collect(); + policy.select(accessible) } /// Print the blank stdout line that opens a paragraph, once: `opened` @@ -1221,41 +1217,54 @@ pub async fn run(args: ScanArgs) -> i32 { // delivered, as with an inline send). The flush here is the // backstop that keeps every event ahead of the process exit. let mut telemetry = PendingTelemetry::new(); - let code = Box::pin(run_scan(args, &mut telemetry)).await; + let code = Box::pin(run_scan(args, &mut telemetry, None, true)).await; telemetry.flush().await; code } /// The project directories a hosted or vendored scan's PATHs name: each /// PATH is a directory, or a glob matching directories, relative to -/// `--cwd`. Sorted and deduplicated. -fn project_dirs(cwd: &Path, paths: &[String]) -> Result, String> { - let mut dirs: Vec = Vec::new(); +/// `--cwd`. Sorted and deduplicated; the flag says whether the user named +/// the directory literally (explicit roots skip the built-in default path +/// ignores; glob matches are discovered roots). +fn project_dirs(cwd: &Path, paths: &[String]) -> Result, String> { + let mut dirs: Vec<(PathBuf, bool)> = Vec::new(); for raw in paths { let joined = cwd.join(raw); if raw.contains(['*', '?', '[']) { let pattern = joined.to_string_lossy().into_owned(); let matches = glob::glob(&pattern).map_err(|e| format!("invalid path pattern `{raw}`: {e}"))?; let before = dirs.len(); - dirs.extend(matches.filter_map(Result::ok).filter(|p| p.is_dir())); + dirs.extend( + matches + .filter_map(Result::ok) + .filter(|p| p.is_dir()) + .map(|p| (p, false)), + ); if dirs.len() == before { return Err(format!("`{raw}` matches no directory")); } } else if joined.is_dir() { - dirs.push(joined); + dirs.push((joined, true)); } else { return Err(format!("`{raw}` is not a directory")); } } - dirs.sort(); - dirs.dedup(); + // A directory both named and matched counts as named. + dirs.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1))); + dirs.dedup_by(|later, earlier| later.0 == earlier.0); Ok(dirs) } /// Run a hosted or vendored scan once per project directory its PATHs /// name, as if each were `--cwd`. The exit code is the worst of the runs. -/// `--json` takes one directory, so stdout stays one document. -async fn run_project_dirs(args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { +/// `--json` takes one directory, so stdout stays one document. Every +/// directory must be inside the repository root the policy was read from. +async fn run_project_dirs( + args: ScanArgs, + telemetry: &mut PendingTelemetry, + invocation: &InvocationPolicy, +) -> i32 { let dirs = match project_dirs(&args.common.cwd, &args.paths) { Ok(dirs) => dirs, Err(message) => { @@ -1263,6 +1272,20 @@ async fn run_project_dirs(args: ScanArgs, telemetry: &mut PendingTelemetry) -> i return 2; } }; + if !args.common.is_global() { + for (dir, _) in &dirs { + let resolved = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.clone()); + if !resolved.starts_with(&invocation.repo_root) { + eprintln!( + "Error: `{}` is outside the repository root {}: scan one repository per \ + invocation", + dir.display(), + invocation.repo_root.display() + ); + return 2; + } + } + } if args.common.json && dirs.len() > 1 { eprintln!( "Error: --json takes one project directory ({} given); run one scan per directory", @@ -1271,7 +1294,7 @@ async fn run_project_dirs(args: ScanArgs, telemetry: &mut PendingTelemetry) -> i return 2; } let mut code = 0; - for dir in &dirs { + for (dir, explicit) in &dirs { if dirs.len() > 1 && !args.common.silent { let shown = dir.strip_prefix(&args.common.cwd).unwrap_or(dir); println!("\n== {} ==", shown.display()); @@ -1279,12 +1302,29 @@ async fn run_project_dirs(args: ScanArgs, telemetry: &mut PendingTelemetry) -> i let mut child = args.clone(); child.paths.clear(); child.common.cwd = dir.clone(); - code = code.max(Box::pin(run_scan(child, telemetry)).await); + code = code.max(Box::pin(run_scan(child, telemetry, Some(invocation), *explicit)).await); } code } -async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { +/// Print a policy file that cannot be honored (fail closed, exit 1). +fn report_policy_error(err: &socket_patch_core::policy::PolicyError, args: &ScanArgs) -> i32 { + if args.common.json { + print_json(&policy::policy_error_json(err, &args.paths)); + } else { + eprintln!("Error ({}): {err}", err.code()); + } + 1 +} + +/// `invocation` is the policy a PATH-list parent already loaded (`None` +/// loads it here); `explicit` says whether the user named this root. +async fn run_scan( + mut args: ScanArgs, + telemetry: &mut PendingTelemetry, + invocation: Option<&InvocationPolicy>, + explicit: bool, +) -> i32 { apply_env_toggles(&args.common); // Fold the legacy mode booleans into `args.mode` (see @@ -1296,14 +1336,39 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { return 2; } + // The repo's socket.yml policy, read once per invocation before any + // write (an invalid file fails the run closed). + let loaded; + let invocation = match invocation { + Some(invocation) => invocation, + None => match load_invocation_policy(&args) { + Ok(i) => { + loaded = i; + &loaded + } + Err(PolicyLoadError::Usage(message)) => { + eprintln!("Error: {message}"); + return 2; + } + Err(PolicyLoadError::Policy(err)) => return report_policy_error(&err, &args), + }, + }; + // Hosted and vendored modes rewire a project's lockfiles, so their // PATHs name project directories: one scan per directory. if matches!(args.mode, Some(ScanMode::Hosted) | Some(ScanMode::Vendored)) && !args.paths.is_empty() { - return Box::pin(run_project_dirs(args, telemetry)).await; + return Box::pin(run_project_dirs(args, telemetry, invocation)).await; } + let mut policy = Box::new(ScanPolicy::for_root( + invocation, + &args.common.cwd, + explicit, + args.common.is_global(), + )); + // Positional PATH globs (see `ScanArgs::paths`). An unparseable glob // is a usage error, same exit-2 shape as the mode conflicts. let path_scope = match crate::path_scope::PathScope::parse(&args.paths) { @@ -1334,7 +1399,8 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { let apply = args.mode == Some(ScanMode::Agent); let vendor = args.mode == Some(ScanMode::Vendored); let hosted = args.mode == Some(ScanMode::Hosted); - let prune = args.prune || args.sync; + // `patches.enabled: false` writes nothing, the GC included. + let prune = (args.prune || args.sync) && policy.writes_allowed(); // Hosted mode runs no GC: say so once up front on the human path. The // `--json` path carries it in `redirect.warnings[]`. @@ -1451,6 +1517,34 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { .map(VendorState::purl_keys) .unwrap_or_default(); + // Read existing manifest once for update detection. + let existing_manifest = read_manifest(&manifest_path).await.ok().flatten(); + // Hosted mode records its patches ONLY in the lockfiles (v5 keeps no + // hosted ledger) and vendored mode ONLY in its ledger, so the hosted + // pins and the vendor ledger's purl→uuid records are folded into update + // detection (otherwise their `updates[]` would stay empty). The same + // merged view is the policy's recorded state (the retained set). + let hosted_pin_list: Vec = + if args.common.is_global() { + Vec::new() + } else { + socket_patch_core::patch::redirect::upstream::HostedPin::all( + &crate::commands::discover_wiring(&args.common, &args.common.cwd).await, + ) + }; + let redirect_state = (!args.common.is_global()) + .then(|| crate::commands::hosted_state_from_pins(&hosted_pin_list)); + let hosted_pins: Vec<(String, String)> = hosted_pin_list + .iter() + .map(|pin| (pin.purl.clone(), pin.uuid.clone())) + .collect(); + let update_manifest = merge_ledger_records_for_updates( + existing_manifest.as_ref(), + vendor_state.as_ref().ok(), + &hosted_pins, + ); + policy.set_recorded(update_manifest.as_deref()); + // Filter by --ecosystems if provided let filtered_crawled: Vec<_> = all_crawled .into_iter() @@ -1513,6 +1607,13 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { .collect() }; + // The socket.yml root/ecosystem/package filters, after the flags + // (which only narrow further) and after the prune-universe capture. + let filtered_crawled: Vec<_> = filtered_crawled + .into_iter() + .filter(|pkg| policy.admit_crawled(&pkg.purl)) + .collect(); + let all_purls: Vec = filtered_crawled.iter().map(|p| p.purl.clone()).collect(); let package_count = all_purls.len(); @@ -1522,6 +1623,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { for (code, detail) in &layout_refusals { eprintln!("Warning ({code}): {detail}"); } + policy.print_warnings(args.common.silent); // Hosted mode already printed its own prune-ignored warning. if prune && !hosted { eprintln!("{}", render::PRUNE_SKIPPED_EMPTY); @@ -1568,6 +1670,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { if !layout_refusals.is_empty() { result["warnings"] = layout_refusal_json(&layout_refusals); } + policy.fold_into_json(&mut result); // Hosted mode: a no-op `redirect` block keeps the envelope // schema-consistent with the ≥1-package path. if hosted { @@ -1610,6 +1713,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { &args.paths, ) ); + policy.print_human(args.common.silent, args.common.verbose); } return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } @@ -1647,6 +1751,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { for (code, detail) in &layout_refusals { eprintln!("Warning ({code}): {detail}"); } + policy.print_warnings(args.common.silent); } // Query API in batches @@ -1848,32 +1953,8 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { telemetry_org.as_deref(), ); - // Read existing manifest once for update detection. - let existing_manifest = read_manifest(&manifest_path).await.ok().flatten(); - // Hosted mode records its patches ONLY in the lockfiles (v5 keeps no - // hosted ledger) and vendored mode ONLY in its ledger, so the hosted - // pins and the vendor ledger's purl→uuid records are folded into update - // detection (otherwise their `updates[]` would stay empty). - let hosted_pin_list: Vec = - if args.common.is_global() { - Vec::new() - } else { - socket_patch_core::patch::redirect::upstream::HostedPin::all( - &crate::commands::discover_wiring(&args.common, &args.common.cwd).await, - ) - }; - let redirect_state = (!args.common.is_global()) - .then(|| crate::commands::hosted_state_from_pins(&hosted_pin_list)); - let hosted_pins: Vec<(String, String)> = hosted_pin_list - .iter() - .map(|pin| (pin.purl.clone(), pin.uuid.clone())) - .collect(); - let update_manifest = merge_ledger_records_for_updates( - existing_manifest.as_ref(), - vendor_state.as_ref().ok(), - &hosted_pins, - ); let updates = detect_updates(update_manifest.as_deref(), &all_packages_with_patches); + policy.set_update_purls(updates.iter().map(|u| u.purl.as_str())); // The hosted-wiring probes below take `all_purls` (POST-filter: only // packages this run covered), unlike the PRE-filter `scanned_purls` @@ -1908,6 +1989,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { let detail = line.strip_prefix("Warning: ").unwrap_or(&line); push_scan_json_warning(&mut result, API_BATCH_FAILED, detail); } + policy.fold_into_json(&mut result); // Flag lockfile-only packages (additive; absent means installed). // `normalize_purl` bridges the API's percent-encoded spelling to the // supplement's literal form. @@ -1932,6 +2014,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { &api_client, &all_packages_with_patches, can_access_paid_patches, + &policy, Some(result), telemetry, npm_crawl.as_ref(), @@ -1960,11 +2043,11 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // --- Apply path (if requested) ----------------------------------- if apply { - let selected = match discover_selected( + let selected: Vec = match discover_selected( &api_client, &all_packages_with_patches, can_access_paid_patches, - &args.common, + &policy, false, false, telemetry, @@ -1972,7 +2055,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { ) .await { - Ok(s) => s, + Ok(offers) => offers.selected.into_values().collect(), Err((code, message)) => { emit_discovery_error_json(&mut result, &message); return code; @@ -2084,6 +2167,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { use_public_proxy, &all_packages_with_patches, can_access_paid_patches, + &policy, &mut result, &manifest_path, &socket_dir, @@ -2140,7 +2224,9 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // the embedded VEX. An early "nothing to apply" exit still runs the GC. let (args_ref, manifest_ref, socket_ref) = (&args, &manifest_path, &socket_dir); let (scanned_ref, vendored_ref) = (&scanned_purls, &vendored_purls); + let policy_ref: &ScanPolicy = &policy; let finish_human = move |code: i32| async move { + policy_ref.print_human(silent, verbose); if prune && !vendor && !hosted && code == 0 { gc::run_human_gc( &args_ref.common, @@ -2310,11 +2396,11 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // engine (the same entry as `get --mode hosted`) — it must NOT fall // through to the apply/vendor branches. if hosted { - let selected = match discover_selected( + let selected: Vec = match discover_selected( &api_client, &all_packages_with_patches, can_access_paid_patches, - &args.common, + &policy, human, !silent, telemetry, @@ -2322,12 +2408,13 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { ) .await { - Ok(s) => s, + Ok(offers) => offers.selected.into_values().collect(), // `discover_selected` already printed the failure to stderr. Err((code, _)) => { return code; } }; + policy.print_human(silent, verbose); let pairs: Vec<(String, String)> = selected .iter() .map(|s| (s.purl.clone(), s.uuid.clone())) @@ -2359,14 +2446,13 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // `resolve_mode_flags`) only reports, plus the `--prune` GC. let report_only = args.mode.is_none(); - // Scan always takes the top-ranked patch (see `selection_args`). - let mut select_common = selection_args(&args.common); - select_common.silent |= report_only; + // Scan always takes the top-ranked patch the policy admits. let selected: Vec = - match select_patches(&all_search_results, can_access_paid_patches, &select_common) { - Ok(s) => s, - Err(code) => return code, - }; + select_accessible(all_search_results, can_access_paid_patches, &policy) + .selected + .into_values() + .collect(); + policy.print_human(silent, verbose); // The skip / already-recorded lines below open their own paragraph // under the table's Summary: one blank line before the first of them. @@ -2628,14 +2714,27 @@ mod tests { std::fs::create_dir_all(tmp.path().join(d)).unwrap(); } std::fs::write(tmp.path().join("apps/README"), "").unwrap(); - let rel = |dirs: Vec| -> Vec { + let rel = |dirs: Vec<(PathBuf, bool)>| -> Vec<(String, bool)> { dirs.iter() - .map(|d| d.strip_prefix(tmp.path()).unwrap().to_string_lossy().replace('\\', "/")) + .map(|(d, explicit)| { + ( + d.strip_prefix(tmp.path()).unwrap().to_string_lossy().replace('\\', "/"), + *explicit, + ) + }) .collect() }; let got = project_dirs(tmp.path(), &["apps/*".into(), "libs/core".into(), "apps/web".into()]) .unwrap(); - assert_eq!(rel(got), ["apps/api", "apps/web", "libs/core"]); + // Named literally = explicit (also when a glob matches it too). + assert_eq!( + rel(got), + [ + ("apps/api".to_string(), false), + ("apps/web".to_string(), true), + ("libs/core".to_string(), true) + ] + ); assert!(project_dirs(tmp.path(), &["apps/README".into()]) .unwrap_err() .contains("is not a directory")); @@ -3004,20 +3103,6 @@ mod tests { ); } - #[test] - fn selection_args_never_prompts() { - for common in [ - GlobalArgs::default(), - GlobalArgs { - json: true, - ..GlobalArgs::default() - }, - ] { - let picked = selection_args(&common); - assert!(!picked.json && picked.yes, "scan always takes the top patch"); - } - } - #[test] fn takeover_detail_names_package_and_remediation() { let purls = vec!["pkg:npm/minimist@1.2.2".to_string()]; diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs new file mode 100644 index 00000000..32f99d0c --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -0,0 +1,482 @@ +//! Disk `scan`'s side of the socket.yml patch policy: loading it once per +//! invocation, the per-root and per-package filters, the severity floor in +//! selection, the retained set, and the `policy` JSON block / human line. +//! The policy itself lives in `socket_patch_core::policy`. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use socket_patch_core::api::ranking::cmp_search_results; +use socket_patch_core::api::types::PatchSearchResult; +use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::policy::{ + find_repo_root_with_warnings, patch_severity_order, repo_relative_checked, sanitize, severity_name, + DiskPolicyFs, FilterReason, Offers, PolicyError, PolicySource, PolicyWarning, Root, SelectionPolicy, + PATCHES_DISABLED, +}; +use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; + +use super::ScanArgs; +use crate::hosted_memory::roots::{marker_ecosystem, UNSUPPORTED_MARKERS}; + +/// Why the policy could not be loaded. +pub(crate) enum PolicyLoadError { + /// A malformed flag or env value: exit 2. + Usage(String), + /// A policy file that cannot be honored: exit 1, `errorCode`. + Policy(PolicyError), +} + +/// The canonical spelling filters and the recorded view compare on. +pub(crate) fn canon(purl: &str) -> String { + normalize_purl(strip_purl_qualifiers(purl)).into_owned() +} + +/// The invocation's policy: loaded once, shared by every project +/// directory a PATH list names. +pub(crate) struct InvocationPolicy { + pub policy: SelectionPolicy, + pub repo_root: PathBuf, + pub warnings: Vec, +} + +/// Load the policy for `args` (4.5): `--global` scans have no repo and read +/// no file; everything else reads the repo root's socket.yml. +pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result { + let overrides = args.socket_yml.overrides().map_err(PolicyLoadError::Usage)?; + let cwd = std::fs::canonicalize(&args.common.cwd).unwrap_or_else(|_| args.common.cwd.clone()); + if args.common.is_global() { + let policy = SelectionPolicy::load(&socket_patch_core::policy::MemoryPolicyFs::default(), &overrides) + .map_err(PolicyLoadError::Policy)? + .0; + return Ok(InvocationPolicy { + policy, + repo_root: cwd, + warnings: Vec::new(), + }); + } + let (repo_root, mut warnings) = find_repo_root_with_warnings(&cwd); + let (policy, load_warnings) = + SelectionPolicy::load(&DiskPolicyFs::new(&repo_root), &overrides).map_err(PolicyLoadError::Policy)?; + warnings.extend(load_warnings); + Ok(InvocationPolicy { + policy, + repo_root, + warnings, + }) +} + +/// The marker files of a disk project root: the same lock markers the +/// in-memory engine detects roots by, plus the maven/nuget markers disk +/// scans support. Manifests are not markers (so both engines agree). +pub(crate) fn dir_markers(dir: &Path) -> Vec { + let mut markers: Vec = std::fs::read_dir(dir) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_ok_and(|t| t.is_file() || t.is_symlink())) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|name| { + marker_ecosystem(name).is_some() + || UNSUPPORTED_MARKERS + .iter() + .any(|(_, names)| names.contains(&name.as_str())) + }) + .collect() + }) + .unwrap_or_default(); + markers.sort(); + markers +} + +#[derive(Debug, Clone)] +struct FilteredEntry { + purl: Option, + uuid: Option, + reason: FilterReason, + severity: Option, +} + +#[derive(Debug, Clone)] +struct RetainedEntry { + purl: String, + recorded_uuid: String, + reason: FilterReason, +} + +#[derive(Default)] +struct Report { + filtered: Vec, + retained: Vec, + retained_purls: BTreeSet, + filtered_purls: HashSet, + update_purls: HashSet, + human_printed: bool, +} + +/// One project root's view of the invocation policy (disk scans run one +/// root per `run_scan`). +pub(crate) struct ScanPolicy { + pub policy: SelectionPolicy, + pub warnings: Vec, + /// Repo-relative root directory (`""` for the repo root). + pub project: String, + /// The root filter's verdict (`Ok` for global scans). + root_verdict: Result<(), FilterReason>, + /// Recorded patches of this root: canonical purl → uuid. + recorded: HashMap, + report: Mutex, +} + +impl ScanPolicy { + /// The policy for the project rooted at `root_dir`. + pub(crate) fn for_root(invocation: &InvocationPolicy, root_dir: &Path, explicit: bool, global: bool) -> Self { + let root_dir = std::fs::canonicalize(root_dir).unwrap_or_else(|_| root_dir.to_path_buf()); + let project = repo_relative_checked(&invocation.repo_root, &root_dir).unwrap_or_default(); + let root_verdict = if global { + Ok(()) + } else { + let markers = dir_markers(&root_dir); + invocation.policy.admits_root(&Root { + rel_dir: &project, + markers: &markers, + explicit, + }) + }; + let mut warnings = invocation.warnings.clone(); + if !invocation.policy.enabled() { + warnings.push(PolicyWarning { + code: PATCHES_DISABLED, + detail: "patches.enabled is false in socket.yml: report only, nothing is written \ + (existing patches stay in place; the --prune GC is skipped too)" + .to_string(), + }); + } + Self { + policy: invocation.policy.clone(), + warnings, + project, + root_verdict, + recorded: HashMap::new(), + report: Mutex::new(Report::default()), + } + } + + fn report(&self) -> std::sync::MutexGuard<'_, Report> { + self.report.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Whether anything may be written this run. + pub(crate) fn writes_allowed(&self) -> bool { + self.policy.enabled() + } + + /// Set the merged recorded view (manifest > hosted pins > vendor ledger). + pub(crate) fn set_recorded(&mut self, merged: Option<&PatchManifest>) { + self.recorded = merged + .map(|m| { + m.patches + .iter() + .map(|(purl, record)| (canon(purl), record.uuid.clone())) + .collect() + }) + .unwrap_or_default(); + } + + fn recorded_uuid(&self, purl: &str) -> Option<&str> { + self.recorded.get(&canon(purl)).map(String::as_str) + } + + /// Step 3: the root, ecosystem and package filters. Returns whether the + /// package stays in the batch query. A recorded package the filters + /// exclude stays in the query (so `upgradeAvailable` can be reported) + /// but joins the retained set, which never reaches a writer. + pub(crate) fn admit_crawled(&self, purl: &str) -> bool { + let verdict = self.root_verdict.clone().and_then(|()| self.policy.admits_purl(purl)); + let reason = match verdict { + Ok(()) => return true, + Err(reason) => reason, + }; + let mut report = self.report(); + if let Some(uuid) = self.recorded_uuid(purl) { + let key = canon(purl); + if report.retained_purls.insert(key.clone()) { + report.retained.push(RetainedEntry { + purl: key, + recorded_uuid: uuid.to_string(), + reason, + }); + } + return true; + } + if self.root_verdict.is_err() { + if !report.filtered.iter().any(|f| f.purl.is_none()) { + report.filtered.push(FilteredEntry { + purl: None, + uuid: None, + reason, + severity: None, + }); + } + } else if report.filtered_purls.insert(canon(purl)) { + report.filtered.push(FilteredEntry { + purl: Some(canon(purl)), + uuid: None, + reason, + severity: None, + }); + } + false + } + + /// Record the purls with a newer patch (`updates[]`), for + /// `retained[].upgradeAvailable`. + pub(crate) fn set_update_purls<'a>(&self, purls: impl IntoIterator) { + self.report().update_purls = purls.into_iter().map(canon).collect(); + } + + /// Steps 5-6: group the tier-accessible offers, keep retained packages + /// out, apply the severity floor and pick one patch per package with + /// the canonical ranking. With no floor the result is exactly today's + /// top-ranked selection. The floor never moves a package off its + /// recorded patch unless an admitted patch outranks the recorded one, + /// and a recorded package with nothing above the floor keeps its patch. + pub(crate) fn select(&self, accessible: Vec) -> Offers { + let mut grouped: BTreeMap> = BTreeMap::new(); + { + let report = self.report(); + for offer in accessible { + if report.retained_purls.contains(&canon(&offer.purl)) { + continue; + } + grouped.entry(offer.purl.clone()).or_default().push(offer); + } + } + for group in grouped.values_mut() { + group.sort_by(cmp_search_results); + } + let mut offers = Offers { + unfiltered: BTreeMap::new(), + selected: BTreeMap::new(), + }; + let mut report = self.report(); + for (purl, group) in grouped { + let recorded = self.recorded_uuid(&purl).map(str::to_string); + if !self.policy.enabled() { + let reason = FilterReason::Disabled; + match recorded { + Some(uuid) => { + let key = canon(&purl); + if report.retained_purls.insert(key.clone()) { + report.retained.push(RetainedEntry { + purl: key, + recorded_uuid: uuid, + reason, + }); + } + } + None => report.filtered.push(FilteredEntry { + purl: Some(purl.clone()), + uuid: Some(group[0].uuid.clone()), + severity: Some(patch_severity_order(&group[0])), + reason, + }), + } + continue; + } + let floor_winner = group + .iter() + .position(|p| self.policy.admits_severity(patch_severity_order(p)).is_ok()); + let recorded_at = recorded + .as_deref() + .and_then(|uuid| group.iter().position(|p| p.uuid == uuid)); + let chosen = match (recorded.is_some(), floor_winner, recorded_at) { + // The recorded patch outranks every admitted one: keep it. + (true, Some(w), Some(r)) if r < w => Some(r), + (_, Some(w), _) => Some(w), + // Nothing above the floor: a recorded package keeps its patch. + (true, None, Some(r)) => Some(r), + (true, None, None) => None, + (false, None, _) => { + report.filtered.push(FilteredEntry { + purl: Some(purl.clone()), + uuid: Some(group[0].uuid.clone()), + severity: Some(patch_severity_order(&group[0])), + reason: self + .policy + .admits_severity(patch_severity_order(&group[0])) + .expect_err("no offer passed the floor"), + }); + None + } + }; + if let Some(i) = chosen { + offers.selected.insert(purl.clone(), group[i].clone()); + } + offers.unfiltered.insert(purl, group); + } + offers + } + + /// The top-level `policy` block (4.7). + pub(crate) fn json(&self) -> serde_json::Value { + let report = self.report(); + let (path, sha256) = match self.policy.source() { + PolicySource::File { path, sha256 } => (serde_json::json!(path), serde_json::json!(sha256)), + _ => (serde_json::Value::Null, serde_json::Value::Null), + }; + let (floor, floor_source) = self.policy.min_severity(); + let filtered: Vec = report + .filtered + .iter() + .map(|f| { + serde_json::json!({ + "purl": f.purl, + "uuid": f.uuid, + "project": self.project, + "reason": f.reason.code(), + "detail": f.reason.detail(), + }) + }) + .collect(); + let retained: Vec = report + .retained + .iter() + .map(|r| { + serde_json::json!({ + "purl": r.purl, + "project": self.project, + "recordedUuid": r.recorded_uuid, + "reason": r.reason.code(), + "detail": r.reason.detail(), + "upgradeAvailable": report.update_purls.contains(&r.purl), + }) + }) + .collect(); + serde_json::json!({ + "source": self.policy.source().as_str(), + "path": path, + "sha256": sha256, + "enabled": self.policy.enabled(), + "minSeverity": { + "value": floor.and_then(severity_name), + "source": floor_source.as_str(), + }, + "counts": { "filtered": filtered.len(), "retained": retained.len() }, + "filtered": filtered, + "retained": retained, + }) + } + + /// Put the `policy` block and the policy warnings on a scan `--json` + /// result (idempotent: the block is rebuilt, warnings added once). + pub(crate) fn fold_into_json(&self, result: &mut serde_json::Value) { + result["policy"] = self.json(); + let warnings = result + .as_object_mut() + .expect("scan JSON result is an object") + .entry("warnings") + .or_insert_with(|| serde_json::json!([])); + if let Some(arr) = warnings.as_array_mut() { + for w in &self.warnings { + let present = arr + .iter() + .any(|e| e["code"] == w.code && e["detail"] == w.detail.as_str()); + if !present { + arr.push(serde_json::json!({ "code": w.code, "detail": w.detail })); + } + } + if arr.is_empty() { + result.as_object_mut().map(|o| o.remove("warnings")); + } + } + } + + /// Print the policy warnings (stderr) once, human path. + pub(crate) fn print_warnings(&self, silent: bool) { + if silent { + return; + } + for w in &self.warnings { + eprintln!("Warning ({}): {}", w.code, w.detail); + } + } + + /// The human policy line (stdout), printed at most once per root: the + /// counts, then every filtered critical/high candidate by name (a + /// policy must not hide those silently), or every entry with + /// `--verbose`. + pub(crate) fn print_human(&self, silent: bool, verbose: bool) { + let mut report = self.report(); + if silent || std::mem::replace(&mut report.human_printed, true) { + return; + } + let filtered = report.filtered.len(); + let retained = report.retained.len(); + if filtered == 0 && retained == 0 && matches!(self.policy.source(), PolicySource::None) { + return; + } + let label = match self.policy.source() { + PolicySource::File { path, .. } => format!("Policy ({path})"), + PolicySource::Bypassed => "Policy (socket.yml ignored)".to_string(), + PolicySource::None => "Policy (built-in defaults)".to_string(), + }; + let mut line = format!( + "\n{label}: {} skipped by filters, {} held.", + filtered, + crate::ui::plural(retained, "patched package", "patched packages") + ); + if !self.policy.enabled() { + line.push_str(" Patching is disabled (patches.enabled: false)."); + } + println!("{line}"); + for f in &report.filtered { + let severe = f.severity.is_some_and(|s| s <= 1); + if !(verbose || severe) { + continue; + } + let what = match &f.purl { + Some(purl) => normalize_purl(purl).into_owned(), + None if self.project.is_empty() => "this project".to_string(), + None => format!("project {}", sanitize(&self.project)), + }; + let severity = f + .severity + .and_then(severity_name) + .map(|s| format!(" ({s})")) + .unwrap_or_default(); + println!(" skipped {what}{severity}: {}", f.reason.detail()); + } + if verbose { + for r in &report.retained { + println!( + " held {} at {}: {}", + normalize_purl(&r.purl), + r.recorded_uuid, + r.reason.detail() + ); + } + } + } +} + +/// The JSON error object for a policy file that cannot be honored: scan's +/// error shape plus `errorCode`. +pub(crate) fn policy_error_json(err: &PolicyError, paths: &[String]) -> serde_json::Value { + serde_json::json!({ + "status": "error", + "error": err.to_string(), + "errorCode": err.code(), + "scannedPackages": 0, + "lockfileOnlyPackages": 0, + "packagesWithPatches": 0, + "totalPatches": 0, + "freePatches": 0, + "paidPatches": 0, + "canAccessPaidPatches": false, + "packages": [], + "updates": [], + "paths": paths, + }) +} diff --git a/crates/socket-patch-cli/src/commands/scan/socket_yml_args.rs b/crates/socket-patch-cli/src/commands/scan/socket_yml_args.rs new file mode 100644 index 00000000..211167d0 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/socket_yml_args.rs @@ -0,0 +1,58 @@ +//! `scan`'s `socket.yml` patch-policy flags. + +use clap::Args; +use socket_patch_core::policy::{parse_min_severity, OverrideSource, PolicyOverrides}; + +/// Env binding of `--min-severity`, read by [`SocketYmlArgs::overrides`] +/// (not by clap) so the policy can say which layer set the floor. +pub const MIN_SEVERITY_ENV: &str = "SOCKET_MIN_SEVERITY"; + +#[derive(Args, Clone, Debug, Default)] +pub struct SocketYmlArgs { + /// Ignore the repository's socket.yml patch policy (its `patches` + /// block and `projectIgnorePaths`) for this run. The built-in + /// test/fixture directory ignores still apply + #[arg( + long = "no-socket-yml", + env = "SOCKET_NO_SOCKET_YML", + default_value_t = false, + value_parser = crate::args::parse_bool_flag, + )] + pub no_socket_yml: bool, + + /// Only patch packages whose patch fixes an advisory of at least this + /// severity: critical, high, medium (or moderate), low, or none for no + /// floor. Overrides `patches.minSeverity` in socket.yml. Patches of + /// unknown severity are skipped whenever a floor is set + /// [env: SOCKET_MIN_SEVERITY] + #[arg(long = "min-severity", value_name = "SEVERITY", value_parser = min_severity_value)] + pub min_severity: Option, +} + +fn min_severity_value(value: &str) -> Result { + parse_min_severity(value).map(|_| value.to_string()) +} + +impl SocketYmlArgs { + /// The trusted overrides: `--min-severity` beats `SOCKET_MIN_SEVERITY` + /// (an empty value is unset). `Err` is a usage error (exit 2). + pub fn overrides(&self) -> Result { + let min_severity = match self.min_severity.as_deref() { + Some(flag) => Some(( + parse_min_severity(flag).map_err(|e| format!("--min-severity: {e}"))?, + OverrideSource::Flag, + )), + None => match std::env::var(MIN_SEVERITY_ENV) { + Ok(value) if !value.trim().is_empty() => Some(( + parse_min_severity(&value).map_err(|e| format!("{MIN_SEVERITY_ENV}: {e}"))?, + OverrideSource::Env, + )), + _ => None, + }, + }; + Ok(PolicyOverrides { + bypass: self.no_socket_yml, + min_severity, + }) + } +} diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index db4aa9bb..25b81acb 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -453,6 +453,7 @@ async fn run_vendor_json_path( use_public_proxy: bool, all_packages_with_patches: &[BatchPackagePatches], can_access_paid_patches: bool, + policy: &super::policy::ScanPolicy, result: &mut serde_json::Value, manifest_path: &Path, socket_dir: &Path, @@ -470,11 +471,11 @@ async fn run_vendor_json_path( // Same discovery as `--apply`. Vendored purls are NOT filtered here — // re-vendoring a stale uuid is the point of the flag (same-uuid re-runs // land on the backend's `already_vendored` skip). - let selected = match discover_selected( + let selected: Vec = match discover_selected( api_client, all_packages_with_patches, can_access_paid_patches, - &args.common, + policy, false, false, telemetry, @@ -482,7 +483,7 @@ async fn run_vendor_json_path( ) .await { - Ok(s) => s, + Ok(offers) => offers.selected.into_values().collect(), Err((code, message)) => { emit_discovery_error_json(result, &message); return code; @@ -781,6 +782,7 @@ pub(super) fn boxed_vendor_json_path<'a>( use_public_proxy: bool, all_packages_with_patches: &'a [BatchPackagePatches], can_access_paid_patches: bool, + policy: &'a super::policy::ScanPolicy, result: &'a mut serde_json::Value, manifest_path: &'a Path, socket_dir: &'a Path, @@ -798,6 +800,7 @@ pub(super) fn boxed_vendor_json_path<'a>( use_public_proxy, all_packages_with_patches, can_access_paid_patches, + policy, result, manifest_path, socket_dir, diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 1ed6a66d..97aac035 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -527,6 +527,18 @@ fn scan_json_empty_cwd_emits_updates_key() { "warnings": [], "dryRun": false }, + // v5: the socket.yml patch policy block rides every successful + // scan (no file here: the built-in defaults). + "policy": { + "source": "none", + "path": null, + "sha256": null, + "enabled": true, + "minSeverity": { "value": null, "source": "default" }, + "counts": { "filtered": 0, "retained": 0 }, + "filtered": [], + "retained": [] + }, }); assert_eq!( v, diff --git a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs new file mode 100644 index 00000000..56566739 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -0,0 +1,821 @@ +//! End-to-end tests for the socket.yml patch policy on disk scans: a +//! monorepo with several npm roots (plus a gem, for the ecosystem filter) +//! scanned through the real binary in hosted, agent and vendored mode +//! against a mock patch API that serves a small catalog. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; +use serial_test::serial; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +const ORG: &str = "test-org"; +const TOKEN_SEGMENT: &str = "55555555-5555-4555-8555-555555555555"; + +#[derive(Clone)] +struct Patch { + uuid: &'static str, + name: &'static str, + version: &'static str, + eco: &'static str, + severities: &'static [&'static str], + published: &'static str, +} + +impl Patch { + fn purl(&self) -> String { + format!("pkg:{}/{}@{}", self.eco, self.name, self.version) + } + + fn hosted_url(&self) -> String { + format!( + "http://patch.test/patch/npm/{n}/{v}/{TOKEN_SEGMENT}/{u}/{n}-{v}.tgz", + n = self.name, + v = self.version, + u = self.uuid + ) + } + + fn vulnerabilities(&self) -> Value { + let mut map = serde_json::Map::new(); + for (i, severity) in self.severities.iter().enumerate() { + map.insert( + format!("GHSA-{}-{i:04}", &self.uuid[..4]), + json!({"cves": [], "summary": "s", "severity": severity, "description": "d"}), + ); + } + Value::Object(map) + } + + fn batch_severity(&self) -> &'static str { + let rank = |s: &str| match s { + "critical" => 0, + "high" => 1, + "medium" => 2, + "low" => 3, + _ => 4, + }; + self.severities.iter().copied().min_by_key(|s| rank(s)).unwrap_or("unknown") + } +} + +const P_ALPHA: Patch = Patch { + uuid: "a1a1a1a1-0000-4000-8000-000000000001", + name: "alpha", + version: "1.0.0", + eco: "npm", + severities: &["critical"], + published: "2024-01-01T00:00:00Z", +}; +const P_BETA: Patch = Patch { + uuid: "b1b1b1b1-0000-4000-8000-000000000001", + name: "beta", + version: "1.0.0", + eco: "npm", + severities: &["low"], + published: "2024-01-01T00:00:00Z", +}; +const P_LEFTPAD: Patch = Patch { + uuid: "c1c1c1c1-0000-4000-8000-000000000001", + name: "left-pad", + version: "1.0.0", + eco: "npm", + severities: &["high"], + published: "2024-01-01T00:00:00Z", +}; +const P_GAMMA: Patch = Patch { + uuid: "d1d1d1d1-0000-4000-8000-000000000001", + name: "gamma", + version: "1.0.0", + eco: "npm", + severities: &["high"], + published: "2024-01-01T00:00:00Z", +}; +const P_DELTA: Patch = Patch { + uuid: "e1e1e1e1-0000-4000-8000-000000000001", + name: "delta", + version: "1.0.0", + eco: "npm", + severities: &["high"], + published: "2024-01-01T00:00:00Z", +}; +const P_RACK: Patch = Patch { + uuid: "f1f1f1f1-0000-4000-8000-000000000001", + name: "rack", + version: "1.0.0", + eco: "gem", + severities: &["high"], + published: "2024-01-01T00:00:00Z", +}; +/// A merged (two-advisory) low patch for alpha: ranks first while no floor +/// applies. +const P_ALPHA_MERGED_LOW: Patch = Patch { + uuid: "a2a2a2a2-0000-4000-8000-000000000002", + name: "alpha", + version: "1.0.0", + eco: "npm", + severities: &["low", "low"], + published: "2024-02-01T00:00:00Z", +}; +/// A newer merged patch for alpha (supersedes P_ALPHA). +const P_ALPHA_MERGED_NEW: Patch = Patch { + uuid: "a3a3a3a3-0000-4000-8000-000000000003", + name: "alpha", + version: "1.0.0", + eco: "npm", + severities: &["critical", "high"], + published: "2024-03-01T00:00:00Z", +}; + +fn catalog() -> Vec { + vec![P_ALPHA, P_BETA, P_LEFTPAD, P_GAMMA, P_DELTA, P_RACK] +} + +fn orig_index(name: &str) -> String { + format!("module.exports = () => '{name} orig';\n") +} + +fn patched_index(name: &str) -> String { + format!("module.exports = () => '{name} patched';\n") +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 3 <= bytes.len() { + if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { + out.push(b); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8(out).unwrap() +} + +/// Serve `patches` from every patches route scan uses. +async fn mount_api(server: &MockServer, patches: Vec) { + let by_purl = { + let mut m: BTreeMap> = BTreeMap::new(); + for p in &patches { + m.entry(p.purl()).or_default().push(p.clone()); + } + m + }; + let batch_map = by_purl.clone(); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(move |req: &Request| { + let body: Value = serde_json::from_slice(&req.body).unwrap(); + let mut packages = Vec::new(); + for c in body["components"].as_array().unwrap() { + let purl = c["purl"].as_str().unwrap(); + if let Some(list) = batch_map.get(purl) { + let infos: Vec = list + .iter() + .map(|p| { + json!({ + "uuid": p.uuid, "purl": purl, "tier": "free", "cveIds": [], + "ghsaIds": p.vulnerabilities().as_object().unwrap().keys().collect::>(), + "severity": p.batch_severity(), "title": "fixture" + }) + }) + .collect(); + packages.push(json!({"purl": purl, "patches": infos})); + } + } + ResponseTemplate::new(200).set_body_json(json!({"packages": packages, "canAccessPaidPatches": false})) + }) + .mount(server) + .await; + let detail_map = by_purl.clone(); + Mock::given(method("GET")) + .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .respond_with(move |req: &Request| { + let raw = req.url.path().rsplit('/').next().unwrap(); + let purl = percent_decode(raw); + let list: Vec = detail_map + .get(&purl) + .into_iter() + .flatten() + .map(|p| { + json!({ + "uuid": p.uuid, "purl": purl, "publishedAt": p.published, + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": p.vulnerabilities() + }) + }) + .collect(); + ResponseTemplate::new(200).set_body_json(json!({"patches": list, "canAccessPaidPatches": false})) + }) + .mount(server) + .await; + let by_uuid: BTreeMap = patches.iter().map(|p| (p.uuid.to_string(), p.clone())).collect(); + let refs = by_uuid.clone(); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(move |req: &Request| { + let body: Value = serde_json::from_slice(&req.body).unwrap(); + let mut results = serde_json::Map::new(); + for uuid in body["uuids"].as_array().unwrap() { + let uuid = uuid.as_str().unwrap(); + if let Some(p) = refs.get(uuid) { + results.insert( + uuid.to_string(), + json!({ + "status": "granted", "url": p.hosted_url(), "purl": p.purl(), + "artifacts": [{"kind": "tarball", "url": p.hosted_url(), + "integrity": {"sha512": format!("sha512-PATCHED{}==", &p.uuid[..8])}}], + "registryOverride": null + }), + ); + } + } + ResponseTemplate::new(200).set_body_json(json!({"results": results})) + }) + .mount(server) + .await; + let views = by_uuid; + Mock::given(method("GET")) + .and(path_regex(format!("^/v0/orgs/{ORG}/patches/view/.+$"))) + .respond_with(move |req: &Request| { + use base64::Engine as _; + let uuid = req.url.path().rsplit('/').next().unwrap(); + let Some(p) = views.get(uuid) else { + return ResponseTemplate::new(404); + }; + let before = compute_git_sha256_from_bytes(orig_index(p.name).as_bytes()); + let after_bytes = patched_index(p.name); + let after = compute_git_sha256_from_bytes(after_bytes.as_bytes()); + ResponseTemplate::new(200).set_body_json(json!({ + "uuid": p.uuid, "purl": p.purl(), "publishedAt": p.published, + "files": {"package/index.js": { + "beforeHash": before, "afterHash": after, + "blobContent": base64::engine::general_purpose::STANDARD.encode(after_bytes.as_bytes()) + }}, + "vulnerabilities": p.vulnerabilities(), + "description": "x", "license": "MIT", "tier": "free" + })) + }) + .mount(server) + .await; +} + +/// An npm project root: package.json, installed copies, a v3 lockfile. +fn write_npm_root(dir: &Path, deps: &[&str]) { + std::fs::create_dir_all(dir).unwrap(); + let dep_map: BTreeMap<&str, &str> = deps.iter().map(|d| (*d, "1.0.0")).collect(); + std::fs::write( + dir.join("package.json"), + serde_json::to_string_pretty(&json!({"name": "consumer", "version": "0.0.0", "dependencies": dep_map})) + .unwrap(), + ) + .unwrap(); + let mut packages = serde_json::Map::new(); + packages.insert( + String::new(), + json!({"name": "consumer", "version": "0.0.0", "dependencies": dep_map}), + ); + for name in deps { + let pkg = dir.join("node_modules").join(name); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write(pkg.join("package.json"), format!(r#"{{ "name": "{name}", "version": "1.0.0" }}"#)).unwrap(); + std::fs::write(pkg.join("index.js"), orig_index(name)).unwrap(); + packages.insert( + format!("node_modules/{name}"), + json!({ + "version": "1.0.0", + "resolved": format!("https://registry.npmjs.org/{name}/-/{name}-1.0.0.tgz"), + "integrity": "sha512-UPSTREAMupstream==" + }), + ); + } + let lock = json!({ + "name": "consumer", "version": "0.0.0", "lockfileVersion": 3, "requires": true, + "packages": packages + }); + std::fs::write(dir.join("package-lock.json"), serde_json::to_string_pretty(&lock).unwrap() + "\n").unwrap(); +} + +fn write_gem(dir: &Path, name: &str, version: &str) { + std::fs::create_dir_all(dir.join("vendor/bundle/ruby/3.0.0/gems").join(format!("{name}-{version}")).join("lib")) + .unwrap(); +} + +/// The monorepo: `services/web` (alpha, beta, left-pad + a gem), +/// `services/legacy` (gamma), `services/test` (delta). +struct Repo { + _tmp: tempfile::TempDir, + root: PathBuf, +} + +impl Repo { + fn new(socket_yml: Option<&str>) -> Self { + let tmp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(tmp.path()).unwrap().join("repo"); + std::fs::create_dir_all(root.join(".git")).unwrap(); + write_npm_root(&root.join("services/web"), &["alpha", "beta", "left-pad"]); + write_gem(&root.join("services/web"), "rack", "1.0.0"); + write_npm_root(&root.join("services/legacy"), &["gamma"]); + write_npm_root(&root.join("services/test"), &["delta"]); + if let Some(text) = socket_yml { + std::fs::write(root.join("socket.yml"), text).unwrap(); + } + Self { _tmp: tmp, root } + } + + fn dir(&self, rel: &str) -> PathBuf { + self.root.join(rel) + } + + fn lock(&self, rel: &str) -> String { + std::fs::read_to_string(self.dir(rel).join("package-lock.json")).unwrap() + } + + fn snapshot(&self) -> BTreeMap> { + fn walk(dir: &Path, root: &Path, out: &mut BTreeMap>) { + for entry in std::fs::read_dir(dir).unwrap().filter_map(Result::ok) { + let path = entry.path(); + if entry.file_type().unwrap().is_dir() { + walk(&path, root, out); + } else { + let rel = path.strip_prefix(root).unwrap().to_string_lossy().into_owned(); + out.insert(rel, std::fs::read(&path).unwrap()); + } + } + } + let mut out = BTreeMap::new(); + walk(&self.root, &self.root, &mut out); + out + } +} + +/// Run the binary with ambient `SOCKET_*` scrubbed; `(code, stdout, stderr)`. +fn run_cli(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.args(args).current_dir(cwd); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { + cmd.env_remove(key); + } + } + cmd.env_remove("GIT_CEILING_DIRECTORIES").env_remove("VIRTUAL_ENV"); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + // The fixture's hosted pins name this origin; it makes them recorded. + cmd.env("SOCKET_PATCH_SERVER_URL", "http://patch.test"); + let absent = cwd.join(".absent-npm-config"); + for var in [ + "NPM_CONFIG_USERCONFIG", + "npm_config_userconfig", + "NPM_CONFIG_GLOBALCONFIG", + "npm_config_globalconfig", + "PREFIX", + ] { + cmd.env(var, &absent); + } + cmd.env("NPM_CONFIG_ALLOW_REMOTE", "").env("npm_config_allow_remote", ""); + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd.output().expect("spawn socket-patch"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn scan(cwd: &Path, api: &str, extra: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { + let mut args = vec![ + "scan", + "--yes", + "--cwd", + cwd.to_str().unwrap(), + "--api-url", + api, + "--org", + ORG, + "--api-token", + "fake", + "--batch-size", + "100", + ]; + args.extend_from_slice(extra); + run_cli(cwd, &args, env) +} + +fn scan_json(cwd: &Path, api: &str, extra: &[&str], env: &[(&str, &str)]) -> (i32, Value) { + let mut args = vec!["--json"]; + args.extend_from_slice(extra); + let (code, stdout, stderr) = scan(cwd, api, &args, env); + let doc: Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("stdout must be JSON ({e})\nstdout=\n{stdout}\nstderr=\n{stderr}")); + (code, doc) +} + +fn filtered(doc: &Value) -> Vec<(Option, String)> { + doc["policy"]["filtered"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["purl"].as_str().map(str::to_string), f["reason"].as_str().unwrap().to_string())) + .collect() +} + +fn filtered_reason<'a>(doc: &'a Value, purl: &str) -> &'a Value { + doc["policy"]["filtered"] + .as_array() + .unwrap() + .iter() + .find(|f| f["purl"] == purl) + .unwrap_or_else(|| panic!("{purl} not in policy.filtered: {:#}", doc["policy"])) +} + +fn warning_codes(doc: &Value) -> Vec { + doc["warnings"] + .as_array() + .map(|w| w.iter().filter_map(|e| e["code"].as_str().map(str::to_string)).collect()) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Hosted +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn hosted_filters_by_ecosystem_package_and_severity() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some( + "version: 2\npatches:\n ecosystems: [npm]\n ignorePackages: [\"pkg:npm/left-pad\"]\n minSeverity: high\n", + )); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + + let lock = repo.lock("services/web"); + assert!(lock.contains(&P_ALPHA.hosted_url()), "alpha is patched:\n{lock}"); + assert!(!lock.contains(P_BETA.uuid), "beta is below the floor:\n{lock}"); + assert!(!lock.contains(P_LEFTPAD.uuid), "left-pad is ignored:\n{lock}"); + + let policy = &doc["policy"]; + assert_eq!(policy["source"], "file"); + assert_eq!(policy["path"], "socket.yml"); + assert_eq!(policy["sha256"].as_str().unwrap().len(), 64); + assert_eq!(policy["enabled"], true); + assert_eq!(policy["minSeverity"], json!({"value": "high", "source": "file"})); + let beta = filtered_reason(&doc, "pkg:npm/beta@1.0.0"); + assert_eq!(beta["reason"], "policy_severity"); + assert_eq!(beta["detail"], "low < high"); + assert_eq!(beta["uuid"], P_BETA.uuid); + assert_eq!(beta["project"], "services/web"); + let left_pad = filtered_reason(&doc, "pkg:npm/left-pad@1.0.0"); + assert_eq!(left_pad["reason"], "policy_package_ignored"); + assert_eq!(left_pad["uuid"], Value::Null, "filtered before any patch lookup"); + assert_eq!(left_pad["detail"], "pkg:npm/left-pad (patches.ignorePackages)"); + let rack = filtered_reason(&doc, "pkg:gem/rack@1.0.0"); + assert_eq!(rack["reason"], "policy_ecosystem"); + assert_eq!(policy["counts"]["filtered"], 3); + assert_eq!(policy["counts"]["retained"], 0); + // Packages filtered before lookup are never queried. + let reqs = server.received_requests().await.unwrap(); + for r in &reqs { + if r.url.path().ends_with("/patches/batch") { + let body = String::from_utf8_lossy(&r.body); + assert!(!body.contains("left-pad") && !body.contains("rack"), "{body}"); + } + } + assert_eq!(doc["redirect"]["redirected"], 1, "{:#}", doc["redirect"]); +} + +#[tokio::test] +#[serial] +async fn hosted_dry_run_makes_the_same_decisions_and_writes_nothing() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n minSeverity: high\n ecosystems: [npm]\n")); + let before = repo.snapshot(); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--dry-run"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.snapshot(), before, "a dry run changes no bytes"); + assert_eq!(doc["redirect"]["redirected"], 2, "alpha and left-pad: {:#}", doc["redirect"]); + assert_eq!(filtered_reason(&doc, "pkg:npm/beta@1.0.0")["reason"], "policy_severity"); +} + +#[tokio::test] +#[serial] +async fn path_globs_apply_default_ignores_and_ignore_paths_human() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n ignorePaths: [\"/services/legacy/\"]\n")); + let legacy = repo.lock("services/legacy"); + let test_lock = repo.lock("services/test"); + let (code, stdout, stderr) = scan(&repo.root, &server.uri(), &["services/*"], &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(repo.lock("services/web").contains(&P_ALPHA.hosted_url())); + assert_eq!(repo.lock("services/legacy"), legacy, "ignored by patches.ignorePaths"); + assert_eq!(repo.lock("services/test"), test_lock, "a discovered test/ root is a built-in ignore"); + assert!(stdout.contains("Policy (socket.yml)"), "{stdout}"); + + // Named literally, the test/ root is explicit: defaults do not apply. + let (code, stdout, stderr) = scan(&repo.root, &server.uri(), &["services/test"], &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(repo.lock("services/test").contains(&P_DELTA.hosted_url())); +} + +#[tokio::test] +#[serial] +async fn include_paths_limit_roots() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n includePaths: [\"/services/legacy/\"]\n")); + let web = repo.lock("services/web"); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.lock("services/web"), web); + assert_eq!( + filtered(&doc), + vec![(None, "policy_path_not_included".to_string())], + "a root filtered as a whole is one entry with purl null" + ); + let (code, doc) = scan_json(&repo.dir("services/legacy"), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert!(repo.lock("services/legacy").contains(&P_GAMMA.hosted_url())); + assert_eq!(doc["policy"]["counts"]["filtered"], 0); +} + +#[tokio::test] +#[serial] +async fn invalid_file_fails_closed_before_any_request_or_write() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n minSeverty: high\n")); + let before = repo.snapshot(); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 1); + assert_eq!(doc["status"], "error"); + assert_eq!(doc["errorCode"], "socket_yml_invalid"); + let message = doc["error"].as_str().unwrap(); + assert!(message.contains("patches.minSeverty"), "{message}"); + assert!(message.contains("did you mean `minSeverity`"), "{message}"); + assert!(message.contains("--no-socket-yml"), "{message}"); + assert!(doc.get("policy").is_none()); + assert_eq!(repo.snapshot(), before); + assert!(server.received_requests().await.unwrap().is_empty(), "no request before the policy loads"); + + // Human output names the code on stderr, same exit code. + let (code, _, stderr) = scan(&repo.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 1); + assert!(stderr.contains("socket_yml_invalid"), "{stderr}"); + + // --no-socket-yml (and its env var) skips the file. + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--no-socket-yml", "--dry-run"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(doc["policy"]["source"], "bypassed"); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--dry-run"], &[("SOCKET_NO_SOCKET_YML", "1")]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(doc["policy"]["source"], "bypassed"); +} + +#[tokio::test] +#[serial] +async fn both_files_disagreeing_is_ambiguous() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n maxNewPatches: 1\n")); + std::fs::write(repo.root.join("socket.yaml"), "version: 2\npatches:\n maxNewPatches: 2\n").unwrap(); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 1); + assert_eq!(doc["errorCode"], "socket_yml_ambiguous"); +} + +#[tokio::test] +#[serial] +async fn severity_flag_and_env_override_the_file() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n minSeverity: high\n")); + let web = repo.dir("services/web"); + let (code, doc) = scan_json(&web, &server.uri(), &["--dry-run", "--min-severity", "none"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(doc["policy"]["minSeverity"], json!({"value": null, "source": "flag"})); + assert_eq!(doc["redirect"]["redirected"], 3, "beta too once the floor is lifted"); + + let (code, doc) = scan_json(&web, &server.uri(), &["--dry-run"], &[("SOCKET_MIN_SEVERITY", "critical")]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(doc["policy"]["minSeverity"], json!({"value": "critical", "source": "env"})); + assert_eq!(doc["redirect"]["redirected"], 1); + + // The flag beats the env; an empty env value is unset. + let (_, doc) = scan_json(&web, &server.uri(), &["--dry-run", "--min-severity", "moderate"], &[("SOCKET_MIN_SEVERITY", "critical")]); + assert_eq!(doc["policy"]["minSeverity"], json!({"value": "medium", "source": "flag"})); + let (_, doc) = scan_json(&web, &server.uri(), &["--dry-run"], &[("SOCKET_MIN_SEVERITY", "")]); + assert_eq!(doc["policy"]["minSeverity"], json!({"value": "high", "source": "file"})); + + // Malformed values are usage errors. + let (code, _, stderr) = scan(&web, &server.uri(), &["--min-severity", "severe"], &[]); + assert_eq!(code, 2, "{stderr}"); + let (code, _, stderr) = scan(&web, &server.uri(), &[], &[("SOCKET_MIN_SEVERITY", "severe")]); + assert_eq!(code, 2, "{stderr}"); + assert!(stderr.contains("SOCKET_MIN_SEVERITY"), "{stderr}"); +} + +#[tokio::test] +#[serial] +async fn narrowing_after_a_hosted_patch_leaves_the_pin_byte_identical() { + let server = MockServer::start().await; + mount_api(&server, vec![P_ALPHA]).await; + let repo = Repo::new(None); + let web = repo.dir("services/web"); + let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + let pinned = repo.lock("services/web"); + assert!(pinned.contains(&P_ALPHA.hosted_url())); + + // A newer merged patch appears, and the repo now ignores alpha. + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n ignorePackages: [alpha]\n").unwrap(); + server.reset().await; + mount_api(&server, vec![P_ALPHA, P_ALPHA_MERGED_NEW]).await; + let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.lock("services/web"), pinned, "retained: not upgraded, not removed"); + let retained = &doc["policy"]["retained"][0]; + assert_eq!(retained["purl"], "pkg:npm/alpha@1.0.0"); + assert_eq!(retained["recordedUuid"], P_ALPHA.uuid); + assert_eq!(retained["reason"], "policy_package_ignored"); + assert_eq!(retained["upgradeAvailable"], true); + assert_eq!(retained["project"], "services/web"); + assert_eq!(doc["policy"]["counts"]["retained"], 1); + + // Same with the whole root excluded, and with enabled: false. + for yml in [ + "version: 2\npatches:\n ignorePaths: [\"services/\"]\n", + "version: 2\npatches:\n enabled: false\n", + ] { + std::fs::write(repo.root.join("socket.yml"), yml).unwrap(); + let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.lock("services/web"), pinned, "{yml}"); + assert_eq!(doc["policy"]["retained"][0]["purl"], "pkg:npm/alpha@1.0.0", "{yml}: {:#}", doc["policy"]); + } +} + +#[tokio::test] +#[serial] +async fn enabled_false_reports_and_writes_nothing() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n enabled: false\n")); + let before = repo.snapshot(); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.snapshot(), before); + assert_eq!(doc["policy"]["enabled"], false); + assert!(warning_codes(&doc).contains(&"patches_disabled".to_string()), "{doc:#}"); + let reasons: Vec = filtered(&doc).into_iter().map(|(_, r)| r).collect(); + assert!(!reasons.is_empty() && reasons.iter().all(|r| r == "policy_disabled"), "{reasons:?}"); + assert_eq!(doc["redirect"]["redirected"], 0); +} + +#[tokio::test] +#[serial] +async fn recorded_merged_patch_below_a_new_floor_is_kept() { + let server = MockServer::start().await; + mount_api(&server, vec![P_ALPHA_MERGED_LOW, P_ALPHA]).await; + let repo = Repo::new(None); + let web = repo.dir("services/web"); + let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + let pinned = repo.lock("services/web"); + assert!(pinned.contains(&P_ALPHA_MERGED_LOW.hosted_url()), "merged ranks first:\n{pinned}"); + + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n minSeverity: high\n").unwrap(); + let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.lock("services/web"), pinned, "the floor never replaces the recorded merged patch"); +} + +#[tokio::test] +#[serial] +async fn path_outside_the_repo_is_a_usage_error() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(None); + let outside = repo.root.parent().unwrap().join("elsewhere"); + write_npm_root(&outside, &["alpha"]); + let (code, _, stderr) = scan(&repo.dir("services"), &server.uri(), &["web", "../../elsewhere"], &[]); + assert_eq!(code, 2, "{stderr}"); + assert!(stderr.contains("outside the repository root"), "{stderr}"); +} + +#[tokio::test] +#[serial] +async fn project_ignore_paths_is_honored_without_a_patches_block() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\nprojectIgnorePaths:\n - \"services/legacy/**\"\n")); + let legacy = repo.lock("services/legacy"); + let (code, doc) = scan_json(&repo.dir("services/legacy"), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.lock("services/legacy"), legacy); + let entry = &doc["policy"]["filtered"][0]; + assert_eq!(entry["reason"], "policy_path_excluded"); + assert_eq!(entry["detail"], "services/legacy/** (projectIgnorePaths)"); + + // A malformed projectIgnorePaths without a patches block only warns. + std::fs::write(repo.root.join("socket.yml"), "version: 2\nprojectIgnorePaths: {a: 1}\n").unwrap(); + let (code, doc) = scan_json(&repo.dir("services/legacy"), &server.uri(), &["--dry-run"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert!(warning_codes(&doc).contains(&"socket_yml_ignored_value".to_string()), "{doc:#}"); +} + +// --------------------------------------------------------------------------- +// Agent +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn agent_mode_applies_only_admitted_patches() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some( + "version: 2\npatches:\n minSeverity: high\n ecosystems: [npm]\n ignorePackages: [\"pkg:npm/left-pad\"]\n", + )); + let web = repo.dir("services/web"); + let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent", "--dry-run"], &[]); + assert_eq!(code, 0, "{doc:#}"); + let planned: Vec<&str> = doc["apply"]["patches"] + .as_array() + .unwrap() + .iter() + .map(|p| p["purl"].as_str().unwrap()) + .collect(); + assert_eq!(planned, ["pkg:npm/alpha@1.0.0"], "{doc:#}"); + + let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent"], &[]); + assert_eq!(code, 0, "{doc:#}"); + let manifest: Value = + serde_json::from_str(&std::fs::read_to_string(web.join(".socket/manifest.json")).unwrap()).unwrap(); + let keys: Vec<&String> = manifest["patches"].as_object().unwrap().keys().collect(); + assert_eq!(keys, ["pkg:npm/alpha@1.0.0"]); + assert_eq!( + std::fs::read_to_string(web.join("node_modules/alpha/index.js")).unwrap(), + patched_index("alpha") + ); + assert_eq!(std::fs::read_to_string(web.join("node_modules/beta/index.js")).unwrap(), orig_index("beta")); +} + +#[tokio::test] +#[serial] +async fn agent_mode_retains_a_recorded_patch_the_policy_now_excludes() { + let server = MockServer::start().await; + mount_api(&server, vec![P_ALPHA]).await; + let repo = Repo::new(None); + let web = repo.dir("services/web"); + let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent"], &[]); + assert_eq!(code, 0, "{doc:#}"); + let manifest_before = std::fs::read(web.join(".socket/manifest.json")).unwrap(); + let installed_before = std::fs::read(web.join("node_modules/alpha/index.js")).unwrap(); + + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n ecosystems: [pypi]\n").unwrap(); + server.reset().await; + mount_api(&server, vec![P_ALPHA, P_ALPHA_MERGED_NEW]).await; + let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(std::fs::read(web.join(".socket/manifest.json")).unwrap(), manifest_before); + assert_eq!(std::fs::read(web.join("node_modules/alpha/index.js")).unwrap(), installed_before); + assert_eq!(doc["policy"]["retained"][0]["reason"], "policy_ecosystem"); + assert_eq!(doc["policy"]["retained"][0]["upgradeAvailable"], true); +} + +// --------------------------------------------------------------------------- +// Vendored +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn vendored_dry_run_previews_only_admitted_patches() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n packages: [\"pkg:npm/beta\", \"pkg:npm/left-pad\"]\n minSeverity: medium\n")); + let before = repo.snapshot(); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--mode", "vendored", "--dry-run"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.snapshot(), before); + let previewed: Vec<&str> = doc["vendor"]["patches"] + .as_array() + .unwrap_or_else(|| panic!("{doc:#}")) + .iter() + .filter_map(|p| p["purl"].as_str()) + .collect(); + assert_eq!(previewed, ["pkg:npm/left-pad@1.0.0"], "{doc:#}"); + assert_eq!(filtered_reason(&doc, "pkg:npm/alpha@1.0.0")["reason"], "policy_package_not_listed"); + assert_eq!(filtered_reason(&doc, "pkg:npm/beta@1.0.0")["reason"], "policy_severity"); +} diff --git a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs index fce505b3..93889c00 100644 --- a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs @@ -220,6 +220,7 @@ async fn cargo_fetch_scan_sync_patches_real_file() { make_writable(&lib_file); let args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -337,6 +338,7 @@ async fn cargo_apply_refuses_on_before_hash_mismatch() { make_writable(&lib_file); let args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -440,6 +442,7 @@ async fn cargo_crawler_finds_real_fetched_crate() { std::env::set_var("CARGO_HOME", &cargo_home); let args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_gem_apply.rs b/crates/socket-patch-cli/tests/in_process_gem_apply.rs index ae9f45c4..2edae8ed 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_apply.rs @@ -199,6 +199,7 @@ async fn gem_install_scan_sync_patches_real_file() { .await; let args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -312,6 +313,7 @@ async fn gem_crawler_finds_real_installed_gem() { .await; let args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs index ecf70b37..dc0506a1 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs @@ -217,6 +217,7 @@ async fn mount_view( fn scan_args(cwd: &Path, api_url: String, all_releases: bool) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs index c5bafe10..c1836650 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs @@ -248,6 +248,7 @@ async fn pypi_install_scan_sync_patches_real_file() { setup_pypi_apply_mock(&server, &before_hash, &after_hash, &patched).await; let mut args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -325,6 +326,7 @@ async fn pypi_scan_then_apply_force_patches_real_file() { // 1. scan --sync to write the manifest + blob. let scan_args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -435,6 +437,7 @@ async fn pypi_apply_dry_run_does_not_modify_file() { setup_pypi_apply_mock(&server, &before_hash, &after_hash, &patched).await; let scan_args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -565,6 +568,7 @@ async fn pypi_crawler_finds_real_installed_six() { .await; let args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs index 18b866a9..31b1b836 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs @@ -291,6 +291,7 @@ async fn mount_view( fn scan_args(tmp: &Path, api_url: String, all_releases: bool) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_python_envs.rs b/crates/socket-patch-cli/tests/in_process_python_envs.rs index 95325a01..1146da46 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -114,6 +114,7 @@ async fn scan_scrubbed(args: ScanArgs) -> i32 { fn default_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index ef2c0309..e868f736 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -45,6 +45,7 @@ const GHSA: &str = "GHSA-rdir-aaaa-bbbb"; fn redirect_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs index 7eb92abf..3d102cc7 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -152,6 +152,7 @@ fn assert_no_ledger(root: &Path) { fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: global(cwd, api_url), diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index b176e9a6..a1da858e 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -80,6 +80,7 @@ fn global(cwd: &Path, api_url: String) -> GlobalArgs { fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: global(cwd, api_url), diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 03da8fcd..3e077a1d 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -79,6 +79,7 @@ async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { /// folds into it). fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs index 9ba3c5a9..86f2cc6b 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs @@ -121,6 +121,7 @@ fn assert_no_ledger(root: &Path) { fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: global(cwd, api_url), diff --git a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs index 094454bc..cf118165 100644 --- a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs @@ -71,6 +71,7 @@ async fn assert_discovered_purl(server: &MockServer, expected_purl: &str) { fn default_scan_args(cwd: &Path, eco: &str, api_url: String) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index 5f6072e0..8cb3154e 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -70,6 +70,7 @@ const GEM_PATCH_REMOTE: &str = "http://patch.test/gems/t0k3nt0k3n/"; fn hosted_scan_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index 859bb611..449bbe1c 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -19,6 +19,7 @@ const UUID: &str = "11111111-1111-4111-8111-111111111111"; fn default_args(cwd: &Path) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 15c8aaf8..0095eab1 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -3246,6 +3246,7 @@ snapshots: /// `in_process_redirect_pnpm.rs` shape). fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: GlobalArgs { From ae6fa061521eae4ef8c5784b6250a095caa01039 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:31:33 +0000 Subject: [PATCH 07/14] Apply socket.yml policy in the in-memory engine selectHostedScanPaths now streams the root socket.yml/socket.yaml and returns them as policyPaths; it drops test and fixture trees through the policy's built-in default ignores instead of a hard-coded segment list (structural excludes like node_modules and vendor stay fixed). The session reads the policy before any root is processed: path filters run before the project limit, ecosystem and package filters on each root's packages, and the severity floor before selection. A listed policy file that arrives without content, or an invalid one, yields policyError with no root processed and no file changed. New options noSocketYml, minSeverity and policyPaths; the result gains a policy block. hosted-bundle and index.d.ts carry the new fields. get now warns policy_bypassed when the repo's socket.yml would have skipped the package it patches. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/get.rs | 4 +- .../src/commands/hosted_bundle.rs | 12 +- .../src/commands/scan/hosted.rs | 1 + .../src/commands/scan/policy.rs | 196 +++++++++++++----- .../src/hosted_memory/limits.rs | 26 +++ .../socket-patch-cli/src/hosted_memory/mod.rs | 196 +++++++++++++++++- .../src/hosted_memory/roots.rs | 87 ++++++-- .../src/hosted_memory/select.rs | 12 ++ .../src/hosted_memory/types.rs | 32 +++ .../socket-patch-cli/tests/cli_parse_scan.rs | 77 +++++++ .../tests/e2e_socket_yml_policy.rs | 42 ++++ .../tests/hosted_memory_common/mod.rs | 24 ++- .../tests/hosted_memory_parity.rs | 182 ++++++++++++++++ crates/socket-patch-core/src/policy/mod.rs | 7 + crates/socket-patch-core/src/policy/tests.rs | 15 ++ crates/socket-patch-node/npm/index.d.ts | 19 +- 16 files changed, 855 insertions(+), 77 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e6a2c1ad..05d7cfab 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -3080,7 +3080,7 @@ pub async fn run(args: GetArgs) -> i32 { // included, so the listing can still show an installed package's paid // fix as `[PAID] (no access)`; selection, the skip records and the // JSON envelope only ever see the accessible share. - let (accessible, listed, narrow_skips, narrow_warnings) = if narrowing_exempt { + let (accessible, listed, narrow_skips, mut narrow_warnings) = if narrowing_exempt { let listed: Vec = search_response.patches.clone(); (accessible, listed, Vec::new(), Vec::new()) } else { @@ -3101,6 +3101,8 @@ pub async fn run(args: GetArgs) -> i32 { .collect(); (kept_accessible, narrowing.kept, skips, narrowing.warnings) }; + // `get` bypasses the repo's socket.yml policy, but says so. + narrow_warnings.extend(super::scan::policy::policy_bypass_warnings(&args.common, &accessible)); // Layout refusals print even when informational output is quieted only // by --json (stderr; the envelope carries them too) — but --silent // mutes them like scan does. diff --git a/crates/socket-patch-cli/src/commands/hosted_bundle.rs b/crates/socket-patch-cli/src/commands/hosted_bundle.rs index 392ac771..b1950f5c 100644 --- a/crates/socket-patch-cli/src/commands/hosted_bundle.rs +++ b/crates/socket-patch-cli/src/commands/hosted_bundle.rs @@ -9,7 +9,8 @@ //! //! Stdin: `{"files": {path: text}, "binaryFiles"?: {path: base64}, //! "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], -//! "pipenvMajor"?: n, "batchSize"?: n}`. Stdout: the engine result +//! "pipenvMajor"?: n, "batchSize"?: n, "noSocketYml"?: bool, +//! "minSeverity"?: severity, "policyPaths"?: [path]}`. Stdout: the engine result //! (`HostedScanResult`, binary contents base64), or //! `{"status":"error","error":{"code","message"}}` with exit 2 for bad //! credentials/bundle input, or exit 1 for an engine failure. @@ -53,6 +54,12 @@ struct Bundle { pipenv_major: Option, #[serde(default)] batch_size: Option, + #[serde(default)] + no_socket_yml: Option, + #[serde(default)] + min_severity: Option, + #[serde(default)] + policy_paths: Option>, } fn print_error(code: &str, message: &str) { @@ -121,6 +128,9 @@ pub async fn run(args: HostedBundleArgs) -> i32 { trust_lockfile_config: Some(!common.no_trust_lockfile_config), npm_allow_remote_config: Some(!common.no_npm_allow_remote_config), project_roots: bundle.project_roots.clone(), + no_socket_yml: bundle.no_socket_yml, + min_severity: bundle.min_severity.clone(), + policy_paths: bundle.policy_paths.clone(), ..HostedScanOptions::default() }; let input = match build_input(bundle, options) { diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index d8426e9f..2304fedb 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1041,6 +1041,7 @@ fn gem_sha_key(purl: &str) -> (String, String) { /// then rewrite ONLY those dependencies' lockfile/registry-config entries to /// point at the hosted vendored patches (the byte-identical counterpart of the /// GitHub-app registry mode). No artifact bytes land in the repo. +#[allow(clippy::too_many_arguments)] pub(super) async fn run_redirect( args: &ScanArgs, api_client: &socket_patch_core::api::client::ApiClient, diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 32f99d0c..97dbc5de 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -90,19 +90,77 @@ pub(crate) fn dir_markers(dir: &Path) -> Vec { markers } +/// One `policy.filtered[]` entry. #[derive(Debug, Clone)] -struct FilteredEntry { - purl: Option, - uuid: Option, - reason: FilterReason, - severity: Option, +pub(crate) struct FilteredEntry { + pub purl: Option, + pub uuid: Option, + pub project: String, + pub reason: FilterReason, + /// The would-be patch's severity order, when a patch was looked up. + pub severity: Option, } +/// One `policy.retained[]` entry. #[derive(Debug, Clone)] -struct RetainedEntry { - purl: String, - recorded_uuid: String, - reason: FilterReason, +pub(crate) struct RetainedEntry { + pub purl: String, + pub project: String, + pub recorded_uuid: String, + pub reason: FilterReason, + pub upgrade_available: bool, +} + +/// The top-level `policy` block (4.7), shared by disk scans and the +/// in-memory engine. +pub(crate) fn policy_block( + policy: &SelectionPolicy, + filtered: &[FilteredEntry], + retained: &[RetainedEntry], +) -> serde_json::Value { + let (path, sha256) = match policy.source() { + PolicySource::File { path, sha256 } => (serde_json::json!(path), serde_json::json!(sha256)), + _ => (serde_json::Value::Null, serde_json::Value::Null), + }; + let (floor, floor_source) = policy.min_severity(); + let filtered: Vec = filtered + .iter() + .map(|f| { + serde_json::json!({ + "purl": f.purl, + "uuid": f.uuid, + "project": f.project, + "reason": f.reason.code(), + "detail": f.reason.detail(), + }) + }) + .collect(); + let retained: Vec = retained + .iter() + .map(|r| { + serde_json::json!({ + "purl": r.purl, + "project": r.project, + "recordedUuid": r.recorded_uuid, + "reason": r.reason.code(), + "detail": r.reason.detail(), + "upgradeAvailable": r.upgrade_available, + }) + }) + .collect(); + serde_json::json!({ + "source": policy.source().as_str(), + "path": path, + "sha256": sha256, + "enabled": policy.enabled(), + "minSeverity": { + "value": floor.and_then(severity_name), + "source": floor_source.as_str(), + }, + "counts": { "filtered": filtered.len(), "retained": retained.len() }, + "filtered": filtered, + "retained": retained, + }) } #[derive(Default)] @@ -204,8 +262,10 @@ impl ScanPolicy { if report.retained_purls.insert(key.clone()) { report.retained.push(RetainedEntry { purl: key, + project: self.project.clone(), recorded_uuid: uuid.to_string(), reason, + upgrade_available: false, }); } return true; @@ -215,6 +275,7 @@ impl ScanPolicy { report.filtered.push(FilteredEntry { purl: None, uuid: None, + project: self.project.clone(), reason, severity: None, }); @@ -223,6 +284,7 @@ impl ScanPolicy { report.filtered.push(FilteredEntry { purl: Some(canon(purl)), uuid: None, + project: self.project.clone(), reason, severity: None, }); @@ -271,14 +333,17 @@ impl ScanPolicy { if report.retained_purls.insert(key.clone()) { report.retained.push(RetainedEntry { purl: key, + project: self.project.clone(), recorded_uuid: uuid, reason, + upgrade_available: false, }); } } None => report.filtered.push(FilteredEntry { purl: Some(purl.clone()), uuid: Some(group[0].uuid.clone()), + project: self.project.clone(), severity: Some(patch_severity_order(&group[0])), reason, }), @@ -302,6 +367,7 @@ impl ScanPolicy { report.filtered.push(FilteredEntry { purl: Some(purl.clone()), uuid: Some(group[0].uuid.clone()), + project: self.project.clone(), severity: Some(patch_severity_order(&group[0])), reason: self .policy @@ -322,51 +388,15 @@ impl ScanPolicy { /// The top-level `policy` block (4.7). pub(crate) fn json(&self) -> serde_json::Value { let report = self.report(); - let (path, sha256) = match self.policy.source() { - PolicySource::File { path, sha256 } => (serde_json::json!(path), serde_json::json!(sha256)), - _ => (serde_json::Value::Null, serde_json::Value::Null), - }; - let (floor, floor_source) = self.policy.min_severity(); - let filtered: Vec = report - .filtered - .iter() - .map(|f| { - serde_json::json!({ - "purl": f.purl, - "uuid": f.uuid, - "project": self.project, - "reason": f.reason.code(), - "detail": f.reason.detail(), - }) - }) - .collect(); - let retained: Vec = report + let retained: Vec = report .retained .iter() - .map(|r| { - serde_json::json!({ - "purl": r.purl, - "project": self.project, - "recordedUuid": r.recorded_uuid, - "reason": r.reason.code(), - "detail": r.reason.detail(), - "upgradeAvailable": report.update_purls.contains(&r.purl), - }) + .map(|r| RetainedEntry { + upgrade_available: report.update_purls.contains(&r.purl), + ..r.clone() }) .collect(); - serde_json::json!({ - "source": self.policy.source().as_str(), - "path": path, - "sha256": sha256, - "enabled": self.policy.enabled(), - "minSeverity": { - "value": floor.and_then(severity_name), - "source": floor_source.as_str(), - }, - "counts": { "filtered": filtered.len(), "retained": retained.len() }, - "filtered": filtered, - "retained": retained, - }) + policy_block(&self.policy, &report.filtered, &retained) } /// Put the `policy` block and the policy warnings on a scan `--json` @@ -480,3 +510,69 @@ pub(crate) fn policy_error_json(err: &PolicyError, paths: &[String]) -> serde_js "paths": paths, }) } + +/// `get`'s `policy_bypassed` warnings: `get` is explicit intent, so it +/// ignores the policy, but says when the repo's socket.yml would have +/// filtered what it is about to patch. Never fails: an unreadable or +/// invalid file just yields no warning. +pub(crate) fn policy_bypass_warnings( + common: &crate::args::GlobalArgs, + patches: &[PatchSearchResult], +) -> Vec<(String, String)> { + if common.is_global() || patches.is_empty() { + return Vec::new(); + } + let cwd = std::fs::canonicalize(&common.cwd).unwrap_or_else(|_| common.cwd.clone()); + let (repo_root, _) = find_repo_root_with_warnings(&cwd); + let Ok((policy, _)) = SelectionPolicy::load( + &DiskPolicyFs::new(&repo_root), + &socket_patch_core::policy::PolicyOverrides::default(), + ) else { + return Vec::new(); + }; + if !matches!(policy.source(), PolicySource::File { .. }) { + return Vec::new(); + } + let project = repo_relative_checked(&repo_root, &cwd).unwrap_or_default(); + let markers = dir_markers(&cwd); + let root_verdict = policy.admits_root(&Root { + rel_dir: &project, + markers: &markers, + explicit: true, + }); + let mut by_purl: BTreeMap<&str, Vec<&PatchSearchResult>> = BTreeMap::new(); + for patch in patches { + by_purl.entry(patch.purl.as_str()).or_default().push(patch); + } + let mut out = Vec::new(); + for (purl, mut group) in by_purl { + group.sort_by(|a, b| cmp_search_results(a, b)); + let verdict = if !policy.enabled() { + Err(FilterReason::Disabled) + } else { + root_verdict.clone().and_then(|()| policy.admits_purl(purl)).and_then(|()| { + // The floor only hides a package when none of its patches pass. + match group + .iter() + .map(|p| policy.admits_severity(patch_severity_order(p))) + .find(Result::is_ok) + { + Some(ok) => ok, + None => policy.admits_severity(patch_severity_order(group[0])), + } + }) + }; + if let Err(reason) = verdict { + out.push(( + socket_patch_core::policy::POLICY_BYPASSED.to_string(), + format!( + "{} would be skipped by socket.yml ({}: {}); get patches it anyway", + normalize_purl(purl), + reason.code(), + reason.detail() + ), + )); + } + } + out +} diff --git a/crates/socket-patch-cli/src/hosted_memory/limits.rs b/crates/socket-patch-cli/src/hosted_memory/limits.rs index 4bcdb635..aebe45f4 100644 --- a/crates/socket-patch-cli/src/hosted_memory/limits.rs +++ b/crates/socket-patch-cli/src/hosted_memory/limits.rs @@ -27,6 +27,8 @@ pub(crate) struct ResolvedOptions { pub(crate) provider_concurrency: usize, pub(crate) request_timeout: std::time::Duration, pub(crate) limits: ResolvedLimits, + pub(crate) policy_overrides: socket_patch_core::policy::PolicyOverrides, + pub(crate) policy_paths: Vec, } pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result { @@ -54,6 +56,28 @@ pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result None, + Some(value) => Some(( + socket_patch_core::policy::parse_min_severity(value) + .map_err(|e| EngineError::invalid("invalid_min_severity", format!("minSeverity: {e}")))?, + socket_patch_core::policy::OverrideSource::Flag, + )), + }; + let policy_overrides = socket_patch_core::policy::PolicyOverrides { + bypass: options.no_socket_yml.unwrap_or(false), + min_severity, + }; + let mut policy_paths: Vec = Vec::new(); + for path in options.policy_paths.iter().flatten() { + if !socket_patch_core::policy::POLICY_FILE_NAMES.contains(&path.as_str()) { + return Err(EngineError::invalid( + "invalid_policy_path", + format!("policyPaths entry `{path}` is not a root socket.yml or socket.yaml"), + )); + } + policy_paths.push(path.clone()); + } let project_roots = match &options.project_roots { Some(roots) => { let mut out: Vec = Vec::with_capacity(roots.len()); @@ -103,6 +127,8 @@ pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result+"`; the sha comes from the /// `SOCKET_PATCH_GIT_SHA` build-time variable. @@ -113,6 +119,8 @@ struct RootState { packages: Vec, selected: Vec<(String, String)>, skipped: Vec, + /// Candidates the socket.yml policy withheld (`policy_*` reasons). + policy_skipped: Vec, error: Option, } @@ -328,6 +336,7 @@ fn unrooted_unsupported_warnings<'a>( || dir .split('/') .any(|seg| roots::EXCLUDED_ROOT_SEGMENTS.contains(&seg)) + || roots::default_ignored_dir(dir) { continue; } @@ -372,10 +381,58 @@ async fn engine( let ecosystems = options.ecosystems.as_deref(); let provider = Provider::new(api, options.request_timeout, options.provider_concurrency); + // The repo's socket.yml policy, before any root is processed: a file + // that cannot be honored fails the whole session closed. + let (policy, policy_warnings) = + match SelectionPolicy::load(&memory_policy_fs(&files, &options.policy_paths), &options.policy_overrides) { + Ok(loaded) => loaded, + Err(error) => { + return Ok(policy_error_output(&error, warnings, files_input, bytes_input)); + } + }; + for w in policy_warnings { + warnings.push(EngineWarning::new(w.code, w.detail, None)); + } + if !policy.enabled() { + warnings.push(EngineWarning::new( + PATCHES_DISABLED, + "patches.enabled is false in socket.yml: report only, nothing is written", + None, + )); + } + let mut policy_filtered: Vec = Vec::new(); + let root_list: Vec = match &options.project_roots { Some(roots) => roots.clone(), None => roots::detect_roots(files.keys().map(String::as_str), ecosystems).0, }; + // The full policy (paths from the file too) judges every root before + // the project limit; roots named in `projectRoots` are explicit. + let explicit_roots = options.project_roots.is_some(); + let detected_roots = root_list.clone(); + let root_list: Vec = root_list + .into_iter() + .filter(|root| { + let markers = roots::root_markers(root, files.keys().map(String::as_str)); + match policy.admits_root(&Root { + rel_dir: root, + markers: &markers, + explicit: explicit_roots, + }) { + Ok(()) => true, + Err(reason) => { + policy_filtered.push(FilteredEntry { + purl: None, + uuid: None, + project: root.clone(), + reason, + severity: None, + }); + false + } + } + }) + .collect(); if root_list.len() as u64 > options.limits.max_projects { return Err(EngineError::limit( "max_projects", @@ -388,7 +445,7 @@ async fn engine( } unrooted_unsupported_warnings( files.keys().map(String::as_str), - &root_list, + &detected_roots, ecosystems, &mut warnings, ); @@ -407,6 +464,7 @@ async fn engine( packages: Vec::new(), selected: Vec::new(), skipped: Vec::new(), + policy_skipped: Vec::new(), error: None, }) .collect(); @@ -430,7 +488,20 @@ async fn engine( .filter_map(|e| discover::supplement_purl(&e.purl)) .filter(|p| ecosystem_allowed(ecosystems, p)) .collect(); - state.purls = purls.into_iter().collect(); + let mut admitted: Vec = Vec::with_capacity(purls.len()); + for purl in purls { + match policy.admits_purl(&purl) { + Ok(()) => admitted.push(purl), + Err(reason) => policy_filtered.push(FilteredEntry { + purl: Some(purl), + uuid: None, + project: state.root.clone(), + reason, + severity: None, + }), + } + } + state.purls = admitted; state.summary.scanned_packages = state.purls.len() as u64; } let union_purls: BTreeSet<&str> = states @@ -534,7 +605,14 @@ async fn engine( Some(&state.root), )); } - state.selected = discover::select_top_ranked(&results, can_access_paid); + state.selected = select_with_policy( + &policy, + results, + can_access_paid, + &state.root, + &mut policy_filtered, + &mut state.policy_skipped, + ); } phases.mark("details"); @@ -628,7 +706,7 @@ async fn engine( let mut results: BTreeMap = BTreeMap::new(); for (index, done) in rewritten { let state = &mut states[index]; - let result = finish_root( + let mut result = finish_root( state, done, &records, @@ -637,6 +715,7 @@ async fn engine( &mut changed_binary, &mut warnings, ); + result.skipped.extend(state.policy_skipped.iter().cloned()); results.insert(index, result); } let mut projects: Vec = Vec::with_capacity(states.len()); @@ -655,12 +734,14 @@ async fn engine( options.dry_run, ), }; + let mut skipped = state.skipped.clone(); + skipped.extend(state.policy_skipped.iter().cloned()); projects.push(ProjectResult { root: state.root.clone(), redirect, summary: state.summary.clone(), redirected: Vec::new(), - skipped: state.skipped.clone(), + skipped, error: state.error.clone(), }); } @@ -697,9 +778,113 @@ async fn engine( warnings, stats, engine_version: engine_version(), + policy: Some(policy_block(&policy, &policy_filtered, &[])), + policy_error: None, }) } +/// The root policy files as the session received them. A path selection +/// listed but the host never sent is present without content (never +/// absent: it may narrow the scan). +fn memory_policy_fs(files: &BTreeMap, listed: &[String]) -> MemoryPolicyFs { + let mut fs = MemoryPolicyFs::default(); + for name in POLICY_FILE_NAMES { + let file = match files.get(name).map(|f| &f.entry) { + Some(MemoryEntry::Text(text)) => RootFile::Present(text.as_bytes().to_vec()), + Some(MemoryEntry::Binary(bytes)) => RootFile::Present(bytes.to_vec()), + Some(_) => RootFile::PresentWithoutContent, + None if listed.iter().any(|l| l == name) => RootFile::PresentWithoutContent, + None => continue, + }; + fs.files.insert(name.to_string(), file); + fs.root_names.push(name.to_string()); + } + fs +} + +/// The session result for a policy file that cannot be honored: no root +/// processed, no file changed. +fn policy_error_output( + error: &socket_patch_core::policy::PolicyError, + warnings: Vec, + files_input: u64, + bytes_input: u64, +) -> HostedScanOutput { + HostedScanOutput { + projects: Vec::new(), + changed_files: Vec::new(), + changed_binary_files: Vec::new(), + deleted_files: Vec::new(), + warnings, + stats: EngineStats { + files_input, + bytes_input, + ..EngineStats::default() + }, + engine_version: engine_version(), + policy: None, + policy_error: Some(PolicyErrorInfo { + code: error.code().to_string(), + detail: error.to_string(), + }), + } +} + +/// The tier filter, the severity floor and the per-package ranking (the +/// disk `ScanPolicy::select` without a recorded view, which the in-memory +/// engine does not read yet). With `patches.enabled: false` nothing is +/// selected and every candidate is reported `policy_disabled`. +fn select_with_policy( + policy: &SelectionPolicy, + results: Vec, + can_access_paid: bool, + root: &str, + filtered: &mut Vec, + skipped: &mut Vec, +) -> Vec<(String, String)> { + let accessible: Vec = results + .into_iter() + .filter(|p| can_access_paid || p.tier == "free") + .collect(); + let (admitted, dropped) = if policy.enabled() { + policy.floor_filter(accessible) + } else { + ( + Vec::new(), + accessible + .into_iter() + .map(|p| (p, FilterReason::Disabled)) + .collect(), + ) + }; + let selected = discover::select_top_ranked(&admitted, true); + let chosen: BTreeSet<&str> = selected.iter().map(|(purl, _)| purl.as_str()).collect(); + let mut by_purl: BTreeMap> = BTreeMap::new(); + for (patch, reason) in dropped { + if !chosen.contains(patch.purl.as_str()) { + by_purl.entry(patch.purl.clone()).or_default().push((patch, reason)); + } + } + for (purl, mut group) in by_purl { + group.sort_by(|a, b| socket_patch_core::api::ranking::cmp_search_results(&a.0, &b.0)); + let (winner, reason) = group.swap_remove(0); + skipped.push(SkippedPatch { + purl: purl.clone(), + uuid: winner.uuid.clone(), + reason: reason.code().to_string(), + detail: Some(reason.detail()), + }); + filtered.push(FilteredEntry { + purl: Some(purl), + uuid: Some(winner.uuid.clone()), + project: root.to_string(), + severity: Some(patch_severity_order(&winner)), + reason, + }); + } + selected +} + /// Records → the project's result and changed files. fn finish_root( state: &mut RootState, @@ -857,6 +1042,7 @@ mod tests { packages: Vec::new(), selected: Vec::new(), skipped: Vec::new(), + policy_skipped: Vec::new(), error: None, } } diff --git a/crates/socket-patch-cli/src/hosted_memory/roots.rs b/crates/socket-patch-cli/src/hosted_memory/roots.rs index 002cac15..91cac49b 100644 --- a/crates/socket-patch-cli/src/hosted_memory/roots.rs +++ b/crates/socket-patch-cli/src/hosted_memory/roots.rs @@ -52,19 +52,40 @@ pub(crate) const UNSUPPORTED_MARKERS: [(&str, &[&str]); 2] = [ ]; /// Directory names whose subtrees never hold a project root: installed -/// trees, VCS and tool state, vendored dependencies, and test fixtures. -pub(crate) const EXCLUDED_ROOT_SEGMENTS: [&str; 10] = [ - "node_modules", - ".git", - ".socket", - ".yarn", - "vendor", - "test", - "tests", - "fixtures", - "__fixtures__", - "testdata", -]; +/// trees, VCS and tool state, and vendored dependencies. Structural, so no +/// policy can negate them. (Test and fixture trees are the socket.yml +/// policy's overridable built-in ignores: [`default_ignored_dir`].) +pub(crate) const EXCLUDED_ROOT_SEGMENTS: [&str; 5] = ["node_modules", ".git", ".socket", ".yarn", "vendor"]; + +/// Whether `dir` (repo-relative) is under a built-in default ignore of the +/// socket.yml policy (`test/`, `tests/`, `fixtures/`, …, any case). +pub(crate) fn default_ignored_dir(dir: &str) -> bool { + !dir.is_empty() + && socket_patch_core::policy::builtin_defaults() + .admits_root(&socket_patch_core::policy::Root { + rel_dir: dir, + markers: &[], + explicit: false, + }) + .is_err() +} + +/// The marker basenames of `root` among `paths` (the files the policy's +/// path filters test for that root). +pub(crate) fn root_markers<'a>(root: &str, paths: impl IntoIterator) -> Vec { + let mut out: Vec = paths + .into_iter() + .filter_map(|path| { + let (dir, base) = split_path(path); + let marker = marker_ecosystem(base).is_some() + || UNSUPPORTED_MARKERS.iter().any(|(_, names)| names.contains(&base)); + (dir == root && marker).then(|| base.to_string()) + }) + .collect(); + out.sort(); + out.dedup(); + out +} /// The ecosystem a root marker basename belongs to. pub(crate) fn marker_ecosystem(base: &str) -> Option<&'static str> { @@ -151,6 +172,19 @@ pub(crate) fn detect_roots<'a>( .collect(); let mut roots: Vec = Vec::new(); for dir in markers.keys() { + let marker_names: Vec = marker_paths + .get(dir) + .into_iter() + .flatten() + .map(|p| split_path(p).1.to_string()) + .collect(); + let default_ignored = socket_patch_core::policy::builtin_defaults() + .admits_root(&socket_patch_core::policy::Root { + rel_dir: dir, + markers: &marker_names, + explicit: false, + }) + .is_err(); let rush_internal = rush_roots.iter().any(|r| { let internal = |sub: &str| join_root(r, sub); *dir == internal("common/config/rush") @@ -158,7 +192,9 @@ pub(crate) fn detect_roots<'a>( || *dir == internal("common/temp") || dir.starts_with(&format!("{}/", internal("common/temp"))) }); - let reason = if rush_internal { + let reason = if default_ignored { + Some("policy_path_excluded") + } else if rush_internal { Some("rush_internal") } else { None @@ -217,7 +253,28 @@ mod tests { ); assert_eq!(found, vec!["docs"]); assert_eq!(ignored.len(), 4); - assert!(ignored.iter().all(|i| i.reason == "excluded_dir")); + let reason = |path: &str| ignored.iter().find(|i| i.path == path).unwrap().reason.clone(); + assert_eq!(reason("node_modules/x/package-lock.json"), "excluded_dir"); + assert_eq!(reason("a/vendor/b/composer.lock"), "excluded_dir"); + assert_eq!(reason(".socket/vendor/npm/package-lock.json"), "excluded_dir"); + // Test/fixture trees are the policy's overridable built-in ignores. + assert_eq!(reason("test/fixtures/yarn.lock"), "policy_path_excluded"); + } + + #[test] + fn default_ignores_are_case_insensitive_and_marker_based() { + let (found, _) = detect_roots( + ["Tests/app/yarn.lock", "e2e/testdata/go.mod", "apps/testing/package-lock.json"], + None, + ); + assert_eq!(found, vec!["apps/testing"]); + assert!(default_ignored_dir("a/__fixtures__")); + assert!(!default_ignored_dir("")); + assert!(!default_ignored_dir("apps/testing")); + assert_eq!( + root_markers("a", ["a/yarn.lock", "a/package.json", "a/b/yarn.lock", "a/pom.xml"]), + vec!["pom.xml".to_string(), "yarn.lock".to_string()] + ); } #[test] diff --git a/crates/socket-patch-cli/src/hosted_memory/select.rs b/crates/socket-patch-cli/src/hosted_memory/select.rs index b7d939af..cae005f6 100644 --- a/crates/socket-patch-cli/src/hosted_memory/select.rs +++ b/crates/socket-patch-cli/src/hosted_memory/select.rs @@ -255,14 +255,26 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path && !dir .split('/') .any(|seg| EXCLUDED_ROOT_SEGMENTS.contains(&seg)) + && !super::roots::default_ignored_dir(dir) }); if let Some(path) = first { needs.entry(path.clone()).or_insert(Need::Present); } } + // The repo-root policy files: always streamed when listed (a symlinked + // one lands in `symlinks`, and the session then fails closed on it). + let mut policy_paths: Vec = Vec::new(); + for name in socket_patch_core::policy::POLICY_FILE_NAMES { + if blobs.contains_key(name) { + needs.entry(name.to_string()).or_insert(Need::Text); + policy_paths.push(name.to_string()); + } + } + let mut selection = PathSelection { roots, + policy_paths, ..PathSelection::default() }; for (path, need) in needs { diff --git a/crates/socket-patch-cli/src/hosted_memory/types.rs b/crates/socket-patch-cli/src/hosted_memory/types.rs index ed8e3a84..04b07971 100644 --- a/crates/socket-patch-cli/src/hosted_memory/types.rs +++ b/crates/socket-patch-cli/src/hosted_memory/types.rs @@ -100,6 +100,17 @@ pub struct HostedScanOptions { pub request_timeout_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub limits: Option, + /// Ignore the repo's socket.yml (`--no-socket-yml`); default false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub no_socket_yml: Option, + /// `critical|high|medium|moderate|low|none`; beats the file's + /// `patches.minSeverity` (`--min-severity`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_severity: Option, + /// The `policyPaths` path selection returned: each must arrive with + /// content, or the session fails with `policyError`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_paths: Option>, } pub const DEFAULT_BATCH_SIZE: u32 = 100; @@ -278,6 +289,23 @@ pub struct HostedScanOutput { pub warnings: Vec, pub stats: EngineStats, pub engine_version: String, + /// The session-level `policy` block (the CLI's `policy` JSON shape); + /// absent on a `policyError`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, + /// A socket.yml that cannot be honored: no root was processed and no + /// file changed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_error: Option, +} + +/// `HostedScanResult.policyError`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyErrorInfo { + /// `socket_yml_invalid` or `socket_yml_ambiguous`. + pub code: String, + pub detail: String, } /// `TreeEntryInput`. @@ -321,6 +349,10 @@ pub struct PathSelection { pub ignored_count: u64, /// At most [`super::select::IGNORED_SAMPLE_MAX`] entries. pub ignored_sample: Vec, + /// The root socket.yml / socket.yaml the tree lists (pass them back as + /// the session's `policyPaths`). + #[serde(default)] + pub policy_paths: Vec, } /// Engine failure (`finish()` rejection codes). diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 97aac035..9d591160 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -45,6 +45,8 @@ const SCAN_ENV_VARS: &[&str] = &[ "SOCKET_JSON", "SOCKET_LOCK_TIMEOUT", "SOCKET_MANIFEST_PATH", + "SOCKET_MIN_SEVERITY", + "SOCKET_NO_SOCKET_YML", "SOCKET_NO_TRUST_LOCKFILE_CONFIG", "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", "SOCKET_NO_VLT_INSTALL_CLEANUP", @@ -994,3 +996,78 @@ fn no_vlt_install_cleanup_flag_and_env_parse() { _ => panic!("expected Scan"), } } + +/// Parse `scan` under a clean env plus `env`, restoring it afterwards. +fn parse_scan_with_env(extra: &[&str], env: &[(&str, &str)]) -> Result { + with_clean_env(|| { + for (k, v) in env { + std::env::set_var(k, v); + } + let mut argv = vec!["socket-patch", "scan"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv); + for (k, _) in env { + std::env::remove_var(k); + } + cli.map(|c| match c.command { + Commands::Scan(a) => a, + _ => panic!("expected Scan"), + }) + }) +} + +/// `--no-socket-yml` / `SOCKET_NO_SOCKET_YML`: a bool with the repo-wide +/// vocabulary; empty is unset; garbage is a parse error. +#[test] +#[serial_test::serial] +fn no_socket_yml_flag_and_env() { + assert!(!parse_scan(&[]).socket_yml.no_socket_yml); + assert!(parse_scan(&["--no-socket-yml"]).socket_yml.no_socket_yml); + for (value, expected) in [("1", true), ("true", true), ("0", false), ("", false)] { + let args = parse_scan_with_env(&[], &[("SOCKET_NO_SOCKET_YML", value)]).expect("parse"); + assert_eq!(args.socket_yml.no_socket_yml, expected, "{value:?}"); + } + assert!(parse_scan_with_env(&[], &[("SOCKET_NO_SOCKET_YML", "garbage")]).is_err()); +} + +/// `--min-severity` / `SOCKET_MIN_SEVERITY`: the flag beats the env, the +/// layer is recorded, `none` lifts the floor, empty env is unset, and a +/// malformed value is a usage error (the flag at parse time, the env when +/// the overrides are resolved; scan exits 2 either way). +#[test] +#[serial_test::serial] +fn min_severity_flag_and_env() { + use socket_patch_core::policy::OverrideSource; + let overrides = |extra: &[&str], env: &[(&str, &str)]| { + let args = parse_scan_with_env(extra, env).expect("parse"); + with_clean_env(|| { + for (k, v) in env { + std::env::set_var(k, v); + } + let out = args.socket_yml.overrides(); + for (k, _) in env { + std::env::remove_var(k); + } + out + }) + }; + assert_eq!(parse_scan(&[]).socket_yml.min_severity, None); + assert_eq!(overrides(&[], &[]).unwrap().min_severity, None); + assert_eq!( + overrides(&["--min-severity", "High"], &[]).unwrap().min_severity, + Some((Some(1), OverrideSource::Flag)) + ); + assert_eq!( + overrides(&["--min-severity", "none"], &[("SOCKET_MIN_SEVERITY", "critical")]).unwrap().min_severity, + Some((None, OverrideSource::Flag)) + ); + assert_eq!( + overrides(&[], &[("SOCKET_MIN_SEVERITY", "moderate")]).unwrap().min_severity, + Some((Some(2), OverrideSource::Env)) + ); + assert_eq!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "")]).unwrap().min_severity, None); + assert!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "severe")]).is_err()); + assert!(try_parse_scan(&["--min-severity", "severe"]).is_err()); + assert!(overrides(&["--no-socket-yml"], &[]).unwrap().bypass); +} + diff --git a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs index 56566739..02e054b5 100644 --- a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -819,3 +819,45 @@ async fn vendored_dry_run_previews_only_admitted_patches() { assert_eq!(filtered_reason(&doc, "pkg:npm/alpha@1.0.0")["reason"], "policy_package_not_listed"); assert_eq!(filtered_reason(&doc, "pkg:npm/beta@1.0.0")["reason"], "policy_severity"); } + +// --------------------------------------------------------------------------- +// get +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_bypasses_the_policy_with_a_warning() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n ignorePackages: [alpha]\n")); + let web = repo.dir("services/web"); + let args = [ + "get", + "pkg:npm/alpha@1.0.0", + "--json", + "--yes", + "--dry-run", + "--cwd", + web.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]; + let (code, stdout, stderr) = run_cli(&web, &args, &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let doc: Value = serde_json::from_str(&stdout).unwrap(); + let warnings: Vec<&str> = doc["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!( + warnings.iter().any(|w| w.starts_with("(policy_bypassed)") && w.contains("alpha")), + "{doc:#}" + ); + + // An invalid file never fails `get`; it only drops the warning. + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches: [\n").unwrap(); + let (code, stdout, stderr) = run_cli(&web, &args, &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(!stdout.contains("policy_bypassed"), "{stdout}"); +} diff --git a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs index c0e60e47..72ba3b97 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs @@ -312,13 +312,29 @@ pub struct DiskRun { /// node / pipenv / gem subprocesses), `HOME` and the language caches at /// empty directories, no socket-cli config, no telemetry. pub fn run_disk(server: &MockServer, files: &BTreeMap>, dry_run: bool) -> DiskRun { - let project = tempfile::tempdir().unwrap(); + run_disk_in(server, files, "", dry_run) +} + +/// [`run_disk`] with `--cwd` at the repo-relative `cwd_rel` of a checkout +/// (a `.git` directory marks the repo root, so a root `socket.yml` +/// applies); `changed` stays relative to the repo root. +pub fn run_disk_in( + server: &MockServer, + files: &BTreeMap>, + cwd_rel: &str, + dry_run: bool, +) -> DiskRun { + let checkout = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); for (rel, bytes) in files { - let path = project.path().join(rel); + let path = checkout.path().join(rel); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, bytes).unwrap(); } + if !cwd_rel.is_empty() { + std::fs::create_dir_all(checkout.path().join(".git")).unwrap(); + } + let cwd = checkout.path().join(cwd_rel); let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); cmd.env_clear(); for keep in [ @@ -356,7 +372,7 @@ pub fn run_disk(server: &MockServer, files: &BTreeMap>, dry_run: "--json", "--yes", "--cwd", - project.path().to_str().unwrap(), + cwd.to_str().unwrap(), "--org", ORG, "--api-token", @@ -372,7 +388,7 @@ pub fn run_disk(server: &MockServer, files: &BTreeMap>, dry_run: let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let envelope: Value = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("disk --json output is not JSON ({e}):\n{stdout}\n{stderr}")); - let after = read_tree(project.path()); + let after = read_tree(checkout.path()); let changed = after .into_iter() .filter(|(rel, bytes)| files.get(rel) != Some(bytes)) diff --git a/crates/socket-patch-cli/tests/hosted_memory_parity.rs b/crates/socket-patch-cli/tests/hosted_memory_parity.rs index 5876fe19..c26764f0 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -745,3 +745,185 @@ async fn excluded_nested_cargo_project_is_its_own_root_through_selection() { memory.warnings ); } + +/// A three-root repo with a root socket.yml: two copies of the npm +/// fixture and the cargo fixture. +fn policy_repo(socket_yml: &str) -> (Vec, BTreeMap>) { + let npm = fixtures_root().join("redirect/npm/package-lock-v3/basic"); + let cargo = fixtures_root().join("redirect/cargo/cargo/basic"); + let mut patches = patches_from_overrides(&npm.join("overrides.json"), None); + patches.extend(patches_from_overrides(&cargo.join("overrides.json"), None)); + let mut repo: BTreeMap> = BTreeMap::new(); + for (root, dir) in [("apps/web", &npm), ("apps/legacy", &npm), ("services/api", &cargo)] { + for (rel, bytes) in fixture_files(&dir.join("input")) { + repo.insert(format!("{root}/{rel}"), bytes); + } + } + repo.insert("socket.yml".to_string(), socket_yml.as_bytes().to_vec()); + (patches, repo) +} + +fn policy_options() -> socket_patch_cli::hosted_memory::HostedScanOptions { + let mut opts = options(false); + opts.policy_paths = Some(vec!["socket.yml".to_string()]); + opts +} + +/// `(project, purl, reason)` of a `policy.filtered[]` list. +fn filtered_set(policy: &Value) -> std::collections::BTreeSet<(String, Option, String)> { + policy["filtered"] + .as_array() + .unwrap() + .iter() + .map(|f| { + ( + f["project"].as_str().unwrap().to_string(), + f["purl"].as_str().map(str::to_string), + f["reason"].as_str().unwrap().to_string(), + ) + }) + .collect() +} + +#[tokio::test] +async fn parity_socket_yml_filters_the_same_roots_and_packages() { + let (patches, repo) = policy_repo( + "version: 2\npatches:\n ignorePaths: [\"/apps/legacy/\"]\n ignorePackages: [\"pkg:cargo/serde\"]\n", + ); + let server = MockServer::start().await; + mount_api(&server, &patches).await; + let memory = run_engine(&server, build_input(&repo, &[], policy_options())).await; + assert!(memory.policy_error.is_none(), "{:?}", memory.policy_error); + let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); + assert_eq!(roots, vec!["apps/web", "services/api"], "the ignored root is not processed"); + let memory_policy = memory.policy.clone().expect("policy block"); + assert_eq!(memory_policy["source"], "file"); + + let mut disk_filtered = std::collections::BTreeSet::new(); + for root in ["apps/web", "apps/legacy", "services/api"] { + let disk = run_disk_in(&server, &repo, root, false); + assert_eq!(disk.envelope["status"], "success", "{root}: {}", disk.stderr); + assert_eq!(disk.envelope["policy"]["sha256"], memory_policy["sha256"], "{root}"); + disk_filtered.extend(filtered_set(&disk.envelope["policy"])); + if let Some(project) = memory.projects.iter().find(|p| p.root == root) { + assert_eq!(project.redirect, disk.envelope["redirect"], "{root}"); + let prefix = format!("{root}/"); + let memory_changed: BTreeMap> = engine_changed(&memory) + .into_iter() + .filter(|(k, _)| k.starts_with(&prefix)) + .collect(); + assert_eq!(memory_changed, disk.changed, "{root}"); + } else { + assert!(disk.changed.is_empty(), "{root}: an ignored root changes nothing"); + } + } + assert_eq!(filtered_set(&memory_policy), disk_filtered); + assert!(disk_filtered.contains(&("apps/legacy".to_string(), None, "policy_path_excluded".to_string()))); + assert!(disk_filtered.contains(&( + "services/api".to_string(), + Some("pkg:cargo/serde@1.0.190".to_string()), + "policy_package_ignored".to_string() + ))); +} + +#[tokio::test] +async fn parity_socket_yml_severity_floor() { + let (patches, repo) = policy_repo("version: 2\npatches:\n minSeverity: critical\n"); + let server = MockServer::start().await; + mount_api(&server, &patches).await; + let memory = run_engine(&server, build_input(&repo, &[], policy_options())).await; + let web = memory.projects.iter().find(|p| p.root == "apps/web").unwrap(); + assert!(web.redirected.is_empty(), "{:#}", web.redirect); + assert!(web.skipped.iter().any(|s| s.reason == "policy_severity"), "{:?}", web.skipped); + assert!(engine_changed(&memory).is_empty()); + let disk = run_disk_in(&server, &repo, "apps/web", false); + assert!(disk.changed.is_empty()); + assert_eq!(disk.envelope["redirect"], web.redirect); + let memory_web: std::collections::BTreeSet<_> = filtered_set(memory.policy.as_ref().unwrap()) + .into_iter() + .filter(|(project, _, _)| project == "apps/web") + .collect(); + assert_eq!(memory_web, filtered_set(&disk.envelope["policy"])); +} + +#[tokio::test] +async fn memory_policy_file_withheld_or_invalid_is_a_policy_error() { + let (patches, repo) = policy_repo("version: 2\npatches:\n maxNewPatches: 1\n"); + let server = MockServer::start().await; + mount_api(&server, &patches).await; + // Listed by selection but never streamed. + let mut withheld = repo.clone(); + withheld.remove("socket.yml"); + let out = run_engine(&server, build_input(&withheld, &[], policy_options())).await; + let err = out.policy_error.expect("policyError"); + assert_eq!(err.code, "socket_yml_invalid"); + assert!(out.projects.is_empty() && out.changed_files.is_empty() && out.policy.is_none()); + // Streamed present-without-content. + let out = run_engine(&server, build_input(&withheld, &["socket.yml"], policy_options())).await; + assert_eq!(out.policy_error.expect("policyError").code, "socket_yml_invalid"); + // Invalid content. + let (_, bad) = policy_repo("version: 2\npatches:\n apiUrl: https://evil.example\n"); + let out = run_engine(&server, build_input(&bad, &[], policy_options())).await; + let err = out.policy_error.expect("policyError"); + assert!(err.detail.contains("patches.apiUrl"), "{}", err.detail); + assert!(out.changed_files.is_empty()); + // noSocketYml skips it. + let mut opts = policy_options(); + opts.no_socket_yml = Some(true); + let out = run_engine(&server, build_input(&bad, &[], opts)).await; + assert!(out.policy_error.is_none()); + assert_eq!(out.policy.unwrap()["source"], "bypassed"); +} + +#[tokio::test] +async fn memory_min_severity_option_beats_the_file() { + let (patches, repo) = policy_repo("version: 2\npatches:\n minSeverity: critical\n"); + let server = MockServer::start().await; + mount_api(&server, &patches).await; + let mut opts = policy_options(); + opts.min_severity = Some("none".to_string()); + let out = run_engine(&server, build_input(&repo, &[], opts)).await; + let policy = out.policy.unwrap(); + assert_eq!(policy["minSeverity"], serde_json::json!({"value": null, "source": "flag"})); + assert!(out.projects.iter().any(|p| !p.redirected.is_empty())); + let mut bad = policy_options(); + bad.min_severity = Some("severe".to_string()); + assert!(socket_patch_cli::hosted_memory::SessionBuilder::new(bad).is_err()); +} + +#[test] +fn selection_streams_policy_files_and_applies_built_in_ignores() { + use socket_patch_cli::hosted_memory::{select_paths, SelectOptions, TreeEntryInput}; + let blob = |path: &str, mode: &str| TreeEntryInput { + path: path.to_string(), + mode: mode.to_string(), + kind: "blob".into(), + size: Some(1), + }; + let entries = vec![ + blob("socket.yml", "100644"), + blob("socket.yaml", "120000"), + blob("Socket.yml", "100644"), + blob("apps/web/package-lock.json", "100644"), + blob("apps/web/tests/app/package-lock.json", "100644"), + blob("Fixtures/x/yarn.lock", "100644"), + ]; + let selection = select_paths(&entries, &SelectOptions::default()); + assert_eq!(selection.policy_paths, vec!["socket.yml", "socket.yaml"]); + assert!(selection.fetch_text.contains(&"socket.yml".to_string())); + assert!(selection.symlinks.contains(&"socket.yaml".to_string())); + assert_eq!(selection.roots, vec!["apps/web"]); + assert!(selection + .ignored_sample + .iter() + .any(|i| i.path == "apps/web/tests/app/package-lock.json" && i.reason == "policy_path_excluded")); + // Named roots are explicit: the built-in ignores do not apply. + let named = select_paths( + &entries, + &SelectOptions { + project_roots: Some(vec!["apps/web/tests/app".to_string()]), + ..SelectOptions::default() + }, + ); + assert_eq!(named.roots, vec!["apps/web/tests/app"]); +} diff --git a/crates/socket-patch-core/src/policy/mod.rs b/crates/socket-patch-core/src/policy/mod.rs index 466d7f1c..f60a2472 100644 --- a/crates/socket-patch-core/src/policy/mod.rs +++ b/crates/socket-patch-core/src/policy/mod.rs @@ -432,6 +432,13 @@ fn compile(lists: &[(&'static str, &[String])]) -> PathMatcher { .unwrap_or_else(|_| PathMatcher::new(&[]).expect("an empty pattern list always compiles")) } +/// The built-in defaults, compiled once (for callers that only need the +/// default path ignores, e.g. tree-listing root detection). +pub fn builtin_defaults() -> &'static SelectionPolicy { + static DEFAULTS: std::sync::LazyLock = std::sync::LazyLock::new(SelectionPolicy::unrestricted); + &DEFAULTS +} + impl SelectionPolicy { /// No file: only the built-in default ignores. pub fn unrestricted() -> Self { diff --git a/crates/socket-patch-core/src/policy/tests.rs b/crates/socket-patch-core/src/policy/tests.rs index 8f053ffb..b3a75a36 100644 --- a/crates/socket-patch-core/src/policy/tests.rs +++ b/crates/socket-patch-core/src/policy/tests.rs @@ -585,3 +585,18 @@ mod disk { assert_eq!(warnings[0].code, "socket_yml_repo_untrusted"); } } + +#[test] +fn this_repos_socket_yml_loads_and_excludes_its_fixtures() { + let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let (policy, warnings) = + SelectionPolicy::load(&DiskPolicyFs::new(&repo), &PolicyOverrides::default()).expect("valid"); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(matches!(policy.source(), PolicySource::File { path, .. } if path == "socket.yml")); + let lock = strings(&["package-lock.json"]); + let err = policy + .admits_root(&root("crates/socket-patch-core/tests/fixtures/redirect/npm", &lock, true)) + .unwrap_err(); + assert_eq!(err.detail(), "crates/socket-patch-core/tests/fixtures/** (projectIgnorePaths)"); + assert!(policy.admits_root(&root("", &lock, true)).is_ok()); +} diff --git a/crates/socket-patch-node/npm/index.d.ts b/crates/socket-patch-node/npm/index.d.ts index e5fc61dc..e12e8d8c 100644 --- a/crates/socket-patch-node/npm/index.d.ts +++ b/crates/socket-patch-node/npm/index.d.ts @@ -9,6 +9,7 @@ export interface PathSelection { symlinks: string[] // candidate paths that are symlinks (mode 120000) — refuse-to-write ignoredCount: number ignoredSample: { path: string; reason: string }[] // ≤100 + policyPaths: string[] // root socket.yml / socket.yaml the tree lists (also in fetchText or symlinks); pass back as the session's policyPaths } export function selectHostedScanPaths(entries: TreeEntryInput[], options?: { projectRoots?: string[]; ecosystems?: Ecosystem[] }): PathSelection export function hostedScanCandidateFiles(): string[] // debug listing only @@ -48,6 +49,9 @@ export interface HostedScanSessionOptions { providerConcurrency?: number // default 8 requestTimeoutMs?: number // per provider call, default 60000 limits?: HostedScanLimits + noSocketYml?: boolean // ignore the repo's socket.yml (built-in test/fixture ignores still apply); default false + minSeverity?: 'critical' | 'high' | 'medium' | 'moderate' | 'low' | 'none' // beats socket.yml patches.minSeverity + policyPaths?: string[] // selectHostedScanPaths' policyPaths; each must be streamed with content or the session returns policyError } export class HostedScanSession { constructor(options: HostedScanSessionOptions, provider: PatchProvider) @@ -63,7 +67,7 @@ export interface ProjectResult { redirect: Record // same shape as CLI `--json` `redirect` block summary: { scannedPackages: number; packagesWithPatches: number; totalPatches: number; freePatches: number; paidPatches: number; canAccessPaidPatches: boolean } redirected: { purl: string; uuid: string }[] - skipped: { purl: string; uuid: string; reason: string; detail?: string }[] + skipped: { purl: string; uuid: string; reason: string; detail?: string }[] // reasons include the policy_* codes error?: { code: string; message: string } // project-level failure (e.g. corrupt_ledger, patch_lookup_failed) } export interface HostedScanResult { @@ -74,6 +78,19 @@ export interface HostedScanResult { warnings: EngineWarning[] stats: { projects: number; filesInput: number; bytesInput: number; packagesScanned: number; packagesWithPatches: number; patchesSelected: number; patchesRedirected: number; filesChanged: number; providerCalls: Record; phaseMs: Record } engineVersion: string + policy?: PolicyBlock // absent only with policyError + policyError?: { code: 'socket_yml_invalid' | 'socket_yml_ambiguous'; detail: string } // no root processed, no file changed +} +export type PolicyReason = 'policy_disabled' | 'policy_path_excluded' | 'policy_path_not_included' | 'policy_ecosystem' | 'policy_package_not_listed' | 'policy_package_ignored' | 'policy_severity' +export interface PolicyBlock { + source: 'none' | 'file' | 'bypassed' + path: string | null + sha256: string | null + enabled: boolean + minSeverity: { value: 'critical' | 'high' | 'medium' | 'low' | null; source: 'flag' | 'env' | 'file' | 'default' } + counts: { filtered: number; retained: number } + filtered: { purl: string | null; uuid: string | null; project: string; reason: PolicyReason; detail: string }[] + retained: { purl: string; project: string; recordedUuid: string; reason: PolicyReason; detail: string; upgradeAvailable: boolean }[] } export class SocketPatchAddonUnavailableError extends Error { From 48f2e78e591b700ce1242495ffa4f87304282263 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:35:02 +0000 Subject: [PATCH 08/14] Document the socket.yml patch policy CLI_CONTRACT.md gains a "socket.yml patch policy" section (grammar, precedence, paths, lookup, validation, commands, error codes and the policy JSON block), the flag and env rows, and the "narrow or pace" half of the trust-boundary rule. README adds a "Roll out gradually" section with copyable socket.yml recipes, CHANGELOG lists the new policy and the breaking scan changes, and the design docs record the decisions made while building it. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 42 ++++++++++++ README.md | 69 +++++++++++++++++++ crates/socket-patch-cli/CLI_CONTRACT.md | 88 ++++++++++++++++++++++++- docs/design/configuration.md | 18 ++++- docs/design/staged-rollout.md | 57 ++++++++++++++++ 5 files changed, 270 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f92db53..9907bd10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -560,8 +560,50 @@ into the new version's section — see docs/releasing.md. batch is reported as failed (warning, or the all-failed error when it was the only batch) — instead of that one package being skipped silently. +- **scan honors the repo's socket.yml.** `projectIgnorePaths` (the + scanner's key) now also keeps `scan` from patching the matching projects, + in every mode and in the in-memory engine, whether or not the file has a + `patches` block (malformed values there only warn + `socket_yml_ignored_value`). See "socket.yml patch policy" in + CLI_CONTRACT.md. +- **Test and fixture trees are skipped by default when scan discovers + projects.** `test/ tests/ fixtures/ __fixtures__/ testdata/` (any case) + are built-in `ignorePaths` for discovered roots: hosted/vendored + PATH-glob matches (`scan 'services/*'`) and the in-memory engine's + detected roots (which used to skip them through a hard-coded, case- + sensitive segment list). A directory you name (`--cwd`, a literal PATH, + `projectRoots`) is not affected; `ignorePaths: ["!/e2e/tests/"]` + re-includes one. +- **An invalid socket.yml fails scan.** An unparseable file, a misspelled or + invalid `patches` block (unknown key, wrong type, bad glob, `patches` + without `version: 2`), or `socket.yml` and `socket.yaml` that disagree + now fail `scan` before any request or write: exit 1, `errorCode: + socket_yml_invalid` / `socket_yml_ambiguous`, the key path and the fix + in the message. `--no-socket-yml` ignores the file for one run. +- **scan rejects a PATH outside the repository root** (exit 2): one socket.yml + policy per invocation. + ### Added +- **socket.yml patch policy (staged rollout).** A `patches` block in the + repo-root socket.yml narrows what `scan` patches: `enabled` (false = + report only), `includePaths` / `ignorePaths` (gitignore patterns matched + against each project's lockfiles, npm `ignore` semantics), + `ecosystems`, `packages` / `ignorePackages` (`--package` specs), + `minSeverity` (critical|high|medium|moderate|low, judged by the worst + advisory a patch fixes) and `maxNewPatches` (validated; the per-run cap + lands with `--max-new-patches`). Flags only narrow further. A package + that already carries a patch is never removed, upgraded or replaced by + the policy: it is held and reported under `policy.retained[]`. New flags + `--min-severity` / `SOCKET_MIN_SEVERITY` and `--no-socket-yml` / + `SOCKET_NO_SOCKET_YML`; every successful `scan --json` result gains a + top-level `policy` block (`source`, `sha256`, `minSeverity`, `filtered[]`, + `retained[]`) and the human output a `Policy (socket.yml): …` line that + names every skipped critical/high patch. The in-memory engine takes + `noSocketYml` / `minSeverity` / `policyPaths`, `selectHostedScanPaths` + returns `policyPaths`, and the result carries `policy` or `policyError`. + `get` ignores the policy and warns `policy_bypassed`. + - **`scan --package `** (repeatable or comma-separated, env `SOCKET_SCAN_PACKAGES`) scopes a scan to the named packages: a name (`lodash`, `@scope/pkg`, `group:artifact`) or a purl with or without its diff --git a/README.md b/README.md index 645bf414..6231ab44 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,10 @@ socket-patch scan 'services/*' # directory glo hosted and vendored mode each PATH is a project directory, scanned as if it were `--cwd` under an `== ==` header; the worst exit code wins. +To make the choice stick for everyone who runs `scan` in the repo (CI and the Socket +autopatch bot included), put it in `socket.yml` instead — see +[Roll out gradually](#roll-out-gradually-with-socketyml). + ### Patch one specific CVE or advisory ```bash @@ -650,6 +654,8 @@ socket-patch scan [PATHS]... [options] | `--prune` | — | Agent-mode garbage collection after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff/package-archive files. [Vendored](#vendor) packages are exempt from the crawl-based prune, but a vendored entry whose dependency has left the lockfile is reverted. Ignored, with a `redirect_prune_ignored` warning, in hosted mode; without a mode the scan is report-only. | | `--sync` | — | Shorthand for `--mode agent --prune`: the one-flag agent-mode auto-update run. | | `--batch-size ` | `SOCKET_BATCH_SIZE` | Packages per API request (default: `500` on the authenticated API, `100` on the public proxy). A request whose body would exceed 256 KiB is split into smaller ones. | +| `--min-severity ` | `SOCKET_MIN_SEVERITY` | Only patch packages whose patch fixes an advisory of at least `critical`, `high`, `medium` (or `moderate`) or `low`; `none` lifts the floor. Overrides `patches.minSeverity` in socket.yml. Patches of unknown severity are skipped whenever a floor is set. | +| `--no-socket-yml` | `SOCKET_NO_SOCKET_YML` | Ignore the repo's socket.yml patch policy for this run (the built-in test/fixture directory ignores still apply). | | `--all-releases` | `SOCKET_ALL_RELEASES` | Store patches for every release/distribution variant, not just the installed one — PyPI wheel/sdist, RubyGems platform, Maven classifier. Makes the manifest portable across environments (e.g. cross-platform CI caches). | | `--vex ` | `SOCKET_VEX` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX](#inline-vex-on-apply--scan--vendor). | | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | @@ -696,6 +702,69 @@ socket-patch scan --vex socket.vex.json > artifact is the patch); a newer available patch still appears in `updates[]` — re-run > `scan --mode vendored` to take it. +#### Roll out gradually with socket.yml + +A `patches` block in the repo-root `socket.yml` (the file the Socket scanner already +reads; keep `version: 2`) narrows what `scan` may patch, for every mode and for the +in-memory engine behind the Socket autopatch bot. It can only narrow: nothing in it can +name an endpoint or token, pick a mode, or turn off a safety check. + +```yaml +# Critical first: widen by editing one line +version: 2 +patches: + minSeverity: critical # later: high, then low, then remove the key + # (low still skips patches whose severity is unknown) +``` + +```yaml +# One directory first (monorepo) +version: 2 +patches: + includePaths: + - "/services/payments/" + # add "/services/checkout/" next sprint +``` + +```yaml +# One ecosystem, hold one package +version: 2 +patches: + ecosystems: [npm] + ignorePackages: ["pkg:npm/left-pad"] +``` + +```yaml +# Pause: report only; existing patches stay in place +version: 2 +patches: + enabled: false +``` + +- Paths are gitignore patterns (the same rules as `projectIgnorePaths`, which scan now + honors too), matched against each project's lockfiles: `"/services/payments/"`, + `"**/yarn.lock"`, `"examples/**"`. `test/`, `tests/`, `fixtures/`, `__fixtures__/` + and `testdata/` directories are skipped by default when scan discovers projects + (a directory glob such as `scan 'services/*'`); re-include one with a negation + (`ignorePaths: ["!/e2e/tests/"]`). A directory you name yourself is always scanned. +- `packages` / `ignorePackages` take `--package` specs; prefer purls (`pkg:npm/core`), + because a bare name also matches other ecosystems and scoped packages (`core` + matches `@babel/core`). +- Narrowing never removes a patch: a package that already carries one and is now + filtered out is left exactly as it is (reported under `policy.retained[]`). Use + `rollback` or `remove` to take a patch out. +- A broken file fails the scan (exit 1, `errorCode: socket_yml_invalid`) before anything + is written, with the key and the fix in the message — a typo never widens the + rollout. `--no-socket-yml` ignores the file for one run. +- `scan --json` reports what the policy did in a top-level `policy` block + (`jq '.policy.counts'`); the human output adds a `Policy (socket.yml): …` line and + always names skipped critical/high patches. `get` ignores the policy (explicit + intent) and warns `policy_bypassed`. + +Every key: `enabled`, `includePaths`, `ignorePaths`, `ecosystems`, `packages`, +`ignorePackages`, `minSeverity`, `maxNewPatches` — see CLI_CONTRACT.md "socket.yml patch +policy" for the full grammar, precedence and validation rules. + ### `vex` Generate an [OpenVEX](https://github.com/openvex) 0.2.0 attestation describing the diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index f5e53273..c258cade 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -87,6 +87,8 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `scan` | `--package ` (repeatable or comma-separated) | `SOCKET_SCAN_PACKAGES` | (v5.0) Only scan these packages: a name (`lodash`, `@scope/pkg`, `requests`, `group:artifact`; matched against the full name or its last segment, case-insensitively) or a purl with or without a version (`pkg:npm/lodash` matches every version, `pkg:pypi/requests@2.31.0` only that one). Qualifiers are ignored. Filters the crawl like `--ecosystems`, after the prune universe is captured, so `--prune` still judges the full crawl | | `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. `--detached` — the former opt-in for exactly that — is **hidden** and retained for compatibility as a no-op; it is still a usage error (exit 2) without vendored mode in either spelling | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size. Unset (v5.0): `500` on the authenticated API (the server's per-request maximum), `100` on the public proxy; a given value applies on either endpoint (`0` is floored to `1`). A chunk whose request body would exceed 256 KiB (the public proxy's body cap) is split into consecutive smaller chunks, deterministically (greedy, in crawl order). A mid-run downgrade to the proxy keeps the chunks already formed | +| `scan` | `--no-socket-yml` | `SOCKET_NO_SOCKET_YML` | (v5.0) Ignore the repository's socket.yml patch policy (its `patches` block and `projectIgnorePaths`) for this run; the built-in test/fixture ignores still apply. The `policy` block reports `source: "bypassed"`. See "socket.yml patch policy". | +| `scan` | `--min-severity ` | `SOCKET_MIN_SEVERITY` | (v5.0) Severity floor for the patch a package may receive (worst advisory severity; unknown severity is skipped whenever a floor is set). Beats `patches.minSeverity`; the flag beats the env; `none` lifts the floor. A malformed value is exit 2. | | `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not | | `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off` (hidden from `--help`: always fails "not yet implemented"); `--mode ` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): `hosted`, like scan; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | | `remove` | positional `identifier`; `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var | @@ -176,6 +178,84 @@ The hidden alias `--no-apply` on `get --save-only` is **part of the contract** **Python stale-install guard**: after a hosted redirect, `scan` / `get` use the Python crawler to inspect every matching installed package, including Poetry's out-of-tree virtualenvs and `--global-prefix`. A readable file that differs from the patch's `afterHash` emits `redirect_pypi_stale_install` in JSON `redirect.warnings[]` and human stderr. The probe changes no installed files, re-runs on idempotent scans, and falls back to persisted patch records when fresh record fetching fails. Missing/unreadable files alone do not prove staleness; lock-only checkouts stay quiet. Dry runs skip the probe. Same-run VEX excludes positively stale Python packages (qualifier-insensitive), even with `--vex-no-verify` or a healthy copy in another interpreter; if nothing remains to attest, the command exits 1 with `no_applicable_patches`. Reinstall from the rewritten lock in the affected interpreter and verify with `socket-patch vex`. +### socket.yml patch policy (v5.0) + +A repository can **narrow** what `scan` patches with a `patches` block in its root `socket.yml` (the Socket scanner's config file; `version: 2` keeps every other consumer working — they strip or ignore the block). Design record: `docs/design/staged-rollout.md`. + +**Grammar.** Every key is optional; camelCase, like the rest of socket.yml. + +```yaml +version: 2 # required once a patches block exists (integer 2 or string "2") +projectIgnorePaths: ["examples/**"] # the scanner's key; socket-patch honors it too +patches: + enabled: true # bool, default true; false = report only, nothing is written + includePaths: ["/services/payments/"] # gitignore list; absent = every project + ignorePaths: ["/legacy/"] # gitignore list, evaluated after the built-in defaults + ecosystems: [npm, pypi] # any --ecosystems name (npm pypi cargo gem golang maven composer nuget deno) + packages: ["pkg:npm/lodash"] # allowlist in the --package grammar + ignorePackages: ["pkg:npm/left-pad"] # denylist in the --package grammar + minSeverity: high # critical|high|medium|moderate|low (moderate = medium) + maxNewPatches: 5 # integer 0..=4294967295; validated now, the per-run cap lands with `--max-new-patches` +``` + +- Deny wins: `ignorePackages` beats `packages`, ignore paths beat `includePaths`. An empty allowlist (`includePaths: []`, `ecosystems: []`, `packages: []`) is an error ("use `enabled: false`"), never "all". +- Package specs are exactly `--package`'s: a name (full or last segment, case-insensitive) or a purl with or without a version; qualifiers ignored. A bare name matches across ecosystems (`core` matches `@babel/core`), so prefer purls. Invalid: empty, or `pkg:` without a type and a name. +- `minSeverity` judges the patch by the worst severity across the advisories it fixes (the per-package records scan fetches, never the batch summary). With a floor set, a patch of unknown severity is skipped (`minSeverity: low` therefore still skips those). The floor restricts which patch may **win** a package: a lower-ranked patch above the floor can still win. + +**Precedence (flags narrow, never widen).** List filters intersect: `--ecosystems`, `--package` and PATH arguments narrow the file's lists further. Scalars go flag > env > file > default: `--min-severity ` > `SOCKET_MIN_SEVERITY` > `patches.minSeverity` > no floor. `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` skips the file entirely (the built-in default ignores still apply). An empty env value is unset; a malformed flag or env value is a usage error (exit 2). + +**Paths.** Path lists are gitignore patterns with the npm `ignore` package's semantics (the backend's `projectIgnorePaths` matcher): case-insensitive, anchored at the repo root, a leading or middle `/` anchors, a bare name matches at any depth, a trailing `/` matches directories only, `!` negates, the last match wins, and a negation cannot re-include anything under an ignored directory (evaluation walks top-down). Backslash is gitignore's escape character, not a separator. Patterns with a `..` segment, a drive letter, a NUL byte, or over 1024 bytes are rejected. + +- They are matched against a project root's **marker files**, repo-relative: the lockfiles in the root's directory (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock(b)`, `vlt-lock.json`, `rush.json`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt`, `*.py.lock`/`pylock*.toml`, `Cargo.lock`, `go.mod`, `go.sum`, `composer.lock`, `Gemfile.lock`, `gems.locked`, plus the Maven/NuGet markers). A root is ignored iff **every** marker is ignored; with `includePaths`, it is included iff **any** marker matches; a root with no marker is matched as its directory. So `/package-lock.json`, `**/yarn.lock` and `examples/**` mean what they mean to the scanner; `includePaths: ["/*", "!/*/"]` targets only the repo-root project. +- Evaluation order (one combined list): the **built-in defaults** `test/ tests/ fixtures/ __fixtures__/ testdata/`, then `projectIgnorePaths`, then `patches.ignorePaths`. Re-include a default with a negation (`ignorePaths: ["!/e2e/tests/"]`). The defaults apply only to **discovered** roots: hosted/vendored PATH-glob matches and roots the in-memory engine detects. A root you name — `--cwd`, a literal PATH, an in-memory `projectRoots` entry — skips them (the other lists still apply). `node_modules .git .socket .yarn vendor` stay structural excludes of in-memory root detection; no policy negates them. +- A workspace member that shares its root's lockfile is part of that root's project: exclude it with `ignorePackages`, not paths. +- A hosted/vendored PATH that resolves outside the repository root is a usage error (exit 2): one policy per invocation. + +**Lookup.** The repo root is the nearest ancestor of `--cwd` (inclusive) holding `.git` (a directory, or a file for worktrees and submodules), not walking past a `GIT_CEILING_DIRECTORIES` entry or into the home directory (unless `--cwd` is it), and, on Unix, only when `.git` belongs to the current user or root (otherwise warning `socket_yml_repo_untrusted` and `--cwd` is the root). No `.git`: the root is `--cwd`. Only `/socket.yml` and `/socket.yaml` are read, matched by exact directory-entry name (`Socket.yml` is not read: warning `socket_yml_name_case`); nested files never are. A symlinked file is followed only to a regular file inside the repo root. `--global` / `--global-prefix` scans read no file. + +**Validation (fail closed).** Because the file only narrows, a file that cannot be honored never means "no policy". Checked in order: file access (a regular file after resolving, at most 64 KiB, read from the opened handle), encoding (UTF-8; a BOM is stripped and CRLF is fine; UTF-16 and NUL bytes are errors), YAML 1.2 syntax (duplicate keys, a non-mapping top level, nesting deeper than 32 and a second document are errors), a top-level key equal to `patch`/`patches` ignoring case but not exactly `patches`, the version gate (`patches` requires `version: 2`), then the keys. Inside `patches` and `projectIgnorePaths`, anchors, aliases, merge keys (`<<`) and custom tags are errors (aliases elsewhere are never expanded). An unknown key under `patches` is an error with a did-you-mean hint and "a newer socket-patch may support it". Wrong types are errors — no coercion (`"false"` is not a bool; YAML 1.2, so `no` is a string) — as are an unknown severity, an out-of-range `maxNewPatches`, an invalid pattern or spec, a list over 1000 entries and an entry over 1024 bytes. Every error names the file and the key path (`patches.minSeverity`). `projectIgnorePaths` is validated strictly when a `patches` block exists (a single string is coerced to a one-element list); without one, a malformed value only warns `socket_yml_ignored_value` and is ignored, and it is honored whatever the `version`. An empty or comment-only file counts as no file. When both `socket.yml` and `socket.yaml` exist, both are validated; if their `projectIgnorePaths` and `patches` are equal as parsed values `socket.yml` is used, otherwise the run fails with `socket_yml_ambiguous`. + +**Error output.** Before any request or write, `scan` exits **1** with scan's error object plus an additive `errorCode` (`socket_yml_invalid` or `socket_yml_ambiguous`): `{"status": "error", "error": "socket.yml: patches.minSeverty: unknown key … (fix the file, or pass --no-socket-yml to ignore it)", "errorCode": "socket_yml_invalid", …}` with every count at zero; no `policy` block. Human output: `Error (socket_yml_invalid): …` on stderr. The in-memory engine reports `policyError: {code, detail}` with no root processed and no file changed. + +**The trust boundary holds.** No key names an endpoint, a credential, an org, a mode, a download format or a safety switch — such keys are unknown keys and fail validation. Every key only removes candidates or (`maxNewPatches`) delays them; none can add a package or bypass the tier filter, the agent partition, reference grants, containment checks or any refusal. + +**Narrowing never removes.** The policy runs after the `--prune` universe is captured, so `--prune` still judges the full crawl. A package that already carries a recorded patch (the merged recorded view: manifest > hosted lockfile pins > vendor ledger) and is now excluded by paths, ecosystems, packages or `enabled: false` is **retained**: never handed to the hosted rewriters, the vendor engine or agent apply, never upgraded, left byte-identical, and listed under `policy.retained[]` with `upgradeAvailable`. A recorded package whose patches all fall below a new floor keeps its recorded patch, and the floor never replaces a recorded patch that outranks every admitted one (a recorded merged patch stays). Removing a patch is only ever `rollback` / `remove`, or the dependency leaving the lockfile. + +**`enabled: false`.** Discovery and the table still run; nothing is written (the `--prune` GC is skipped too); every candidate is reported `policy_disabled` (recorded ones as retained); warning `patches_disabled`; exit 0. + +**Commands.** `scan` (hosted, vendored, agent; wet and `--dry-run`), the in-memory engine and `hosted-bundle` honor the policy. `get` is explicit intent: it ignores the policy and warns `policy_bypassed` (in `warnings[]`, and on stderr) when socket.yml would have skipped the package; an invalid file never fails `get`, it only drops the warning. `apply`, `list`, `vex`, `rollback`, `remove`, `repair` and `vendor` ignore it. + +**`policy` JSON block** (additive, MINOR; on every successful `scan --json` result, and session-level on the in-memory result): + +```json +"policy": { + "source": "file", + "path": "socket.yml", + "sha256": "…", + "enabled": true, + "minSeverity": {"value": "high", "source": "file"}, + "counts": {"filtered": 3, "retained": 1}, + "filtered": [ + {"purl": "pkg:npm/qs@6.5.2", "uuid": null, "project": "services/legacy", + "reason": "policy_path_excluded", "detail": "/legacy/ (patches.ignorePaths)"} + ], + "retained": [ + {"purl": "pkg:npm/lodash@4.17.20", "project": "", "recordedUuid": "…", + "reason": "policy_package_ignored", "detail": "lodash (patches.ignorePackages)", "upgradeAvailable": true} + ] +} +``` + +- `source`: `none` (no file, an empty file, `--global`, or only a case variant; `path`/`sha256` null), `file` (the file used and the SHA-256 of its bytes), `bypassed` (`--no-socket-yml`). +- `minSeverity.source`: `flag` | `env` | `file` | `default`; `value` null = no floor (`moderate` reads as `medium`). +- `project`: the repo-relative root directory (`""` for the repo root). +- `filtered[]`: `uuid` is null for a package filtered before any patch lookup (path, ecosystem and package reasons — those packages are never queried); a root filtered as a whole is one entry with `purl: null`. A severity entry names the top-ranked patch the floor withheld. `retained[]`: recorded packages the filters hold in place. +- Reason codes (stable): `policy_disabled`, `policy_path_excluded`, `policy_path_not_included`, `policy_ecosystem`, `policy_package_not_listed`, `policy_package_ignored`, `policy_severity` (detail `low < high`, `unknown < high`). In-memory `ProjectResult.skipped[]` carries the post-lookup ones (severity, disabled) with the same codes. +- Every string copied from the file (patterns, specs, key names) is truncated to 200 characters with control characters stripped. +- Warnings ride scan's top-level `warnings[]` (`{code, detail}`): `socket_yml_ignored_value`, `socket_yml_name_case`, `socket_yml_repo_untrusted`, `patches_disabled`. +- Human output: one line after the table, e.g. `Policy (socket.yml): 3 skipped by filters, 1 patched package held.`, then every filtered critical/high candidate by name (a policy must not hide those silently); `--verbose` lists every entry. +- Exit code is unchanged by filtering. + ### Embedded VEX (`apply --vex` / `scan --vex` / `vendor --vex`) `--vex ` folds OpenVEX 0.2.0 generation into `apply`, `scan`, and `vendor`: on a successful run the command writes the document to `` using the same engine as the standalone `vex` command. The `--vex-*` flags mirror `vex`'s `--product` / `--no-verify` / `--doc-id` / `--compact` knobs (namespaced to avoid colliding with the host command), and reuse the standalone env vars (`SOCKET_VEX_PRODUCT`, etc.). They are inert unless `--vex` is set. @@ -1083,6 +1163,8 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_PATCH_VERSION` | `--update ` | (latest) | Local to `--update`; the same pin `install.sh` and the gem launcher honor. Not one of the deprecated legacy `SOCKET_PATCH_*` trio. | | `SOCKET_BATCH_SIZE` | `scan --batch-size` | `500` authenticated / `100` proxy | Local to `scan`. | | `SOCKET_SCAN_PACKAGES` | `scan --package` | (none) | Local to `scan` (v5.0); comma-separated names or purls. | +| `SOCKET_NO_SOCKET_YML` | `scan --no-socket-yml` | `false` | Local to `scan` (v5.0); bool vocabulary, empty = unset. | +| `SOCKET_MIN_SEVERITY` | `scan --min-severity` | (none) | Local to `scan` (v5.0); read by scan (not clap) so the `policy` block can say `source: "env"`; empty = unset, malformed = exit 2. | | `SOCKET_SAVE_ONLY` | `get --save-only` | `false` | Local to `get`. | | `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. Both are **not yet implemented**: the flag parses (boolishly, empty-tolerant) and the command fails up front with a "not yet implemented" error, before any network or disk activity (on `rollback`, with no identifier-shaped target it instead fails "requires an identifier", equally up front). | | `SOCKET_ALL_RELEASES` | `get --all-releases` / `scan --all-releases` | `false` | Local to `get`/`scan`. Download patches for every release/distribution variant, not just the installed one. | @@ -1128,7 +1210,7 @@ Contract properties: - The file is read lazily at most once per process, only when a key is still unresolved after flag + env. - The telemetry endpoint resolver shares the same `apiBaseUrl` chain as API-client construction (`resolve_api_base_url`), so telemetry can never target a different host than the client. - `--offline` semantics are unchanged: reading the local file is not network contact; a config-sourced token is inert offline. -- **Repo-level files never carry endpoints, credentials, or interlock-disablers**: configuration for those comes only from flags, env vars, this user-level file, and built-in defaults — never from files inside the repository being patched (manifest, socket.yml, `.env`, …). +- **Repo-level files never carry endpoints, credentials, or interlock-disablers**: configuration for those comes only from flags, env vars, this user-level file, and built-in defaults — never from files inside the repository being patched (manifest, socket.yml, `.env`, …). A repository file may **narrow or pace** what `scan` patches (socket.yml's `patches` block and `projectIgnorePaths`, see "socket.yml patch policy"). It may never name an endpoint or credential, pick a mode or download format, turn off a safety check, or make `scan` patch anything it would not patch with no file present; the one exception is negating the built-in test/fixture path ignores, which are repo policy by nature. - `--debug` names the source on stderr whenever a setting resolves from the socket-cli config (the token value itself is never echoed). ### Registry override env vars @@ -1632,8 +1714,8 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) | Code | Meaning | |---|---| | `0` | Success | -| `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | -| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg — including the clap-enforced `setup --check --remove` conflict) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `--detached` without vendored mode and `--mode hosted` with `--global`/`--global-prefix` (same enforcement point); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). **Carve-out**: `get`'s self-enforced conflicts have always exited `1` via its error envelope (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`) and the v3.6 `--mode hosted\|vendored --save-only` conflict deliberately follows that get-internal precedent — changing the existing ones to `2` would be a MAJOR exit-code change | +| `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, an invalid or ambiguous socket.yml on `scan` (v5.0), etc.) | +| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg — including the clap-enforced `setup --check --remove` conflict) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `--detached` without vendored mode and `--mode hosted` with `--global`/`--global-prefix` (same enforcement point); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, a `scan` PATH outside the repository root and a malformed `SOCKET_MIN_SEVERITY` (v5.0), `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). **Carve-out**: `get`'s self-enforced conflicts have always exited `1` via its error envelope (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`) and the v3.6 `--mode hosted\|vendored --save-only` conflict deliberately follows that get-internal precedent — changing the existing ones to `2` would be a MAJOR exit-code change | `list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing (a manifest-less project whose vendor ledger holds records or whose lockfiles pin hosted patches is NOT "missing": `list` reads all three sources and exits 0 — see the `manifest_not_found` row). Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. diff --git a/docs/design/configuration.md b/docs/design/configuration.md index 4c4a617c..555b1db4 100644 --- a/docs/design/configuration.md +++ b/docs/design/configuration.md @@ -1,7 +1,8 @@ # Configuration design: env vars, the socket-cli config file, and what we deliberately don't read Status: **implemented** (v3.5); section 4 (`socket.yml` patch policy) is -**planned** for v5.0 (see `staged-rollout.md`). This document records the +**implemented** in v5.0 for its filters (`socket_patch_core::policy`; the +per-run cap follows with `--max-new-patches`, see `staged-rollout.md`). This document records the settled design so future configuration surface grows inside it instead of inventing new mechanisms. @@ -113,6 +114,14 @@ The trust boundary is unchanged and gains its positive half: `remove`, `repair`, `apply`, `vendor`) ignore it; `get` bypasses it with a warning. +Implementation: `socket_patch_core::policy` (`SelectionPolicy::load` over a +`PolicyFs`: `DiskPolicyFs` for a checkout, `MemoryPolicyFs` for the +in-memory engine) parses the file as a YAML 1.2 event stream (serde-saphyr's +parser, so aliases are never expanded) and validates only `version`, +`projectIgnorePaths` and `patches`. Disk scan glue lives in +`commands/scan/policy.rs`; the flags in `commands/scan/socket_yml_args.rs`. +The full contract is CLI_CONTRACT.md "socket.yml patch policy". + ## Explicitly rejected | Idea | Why not | @@ -149,6 +158,13 @@ The trust boundary is unchanged and gains its positive half: ## Test strategy (how this stays true) +- socket.yml policy: table-driven unit tests in + `socket-patch-core/src/policy/` (every validation row, the lookup and + file-access rules, and a golden fixture generated from the npm `ignore` + package by `scripts/gen-ignore-golden.mjs`), the parser contract in + `tests/cli_parse_scan.rs`, disk e2e in `tests/e2e_socket_yml_policy.rs` + and disk/memory parity in `tests/hosted_memory_parity.rs`. + - `tests/cli_config_fallback.rs` spawns the binary against fixture `config.json` files (fresh process per case — the disk read is cached per process) and pins: config token/apiBaseUrl authenticate, `defaultOrg` diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index cbe8a507..1bb12aff 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -1015,6 +1015,63 @@ change), README (recipe R5, `--max-new-patches`), CHANGELOG - If B is ready before A merges, B ships with the file layer passed as `None` and wires it in a follow-up commit on its branch once A lands. +### 9.4 Work item A: decisions made while building + +Gaps and contradictions A resolved with the smallest reasonable decision +(each is also in A's PR description): + +1. **YAML crate.** `serde-saphyr` 1.3.0 (maintained, YAML 1.2), driven + through its re-exported event parser (`serde_saphyr::granit_parser`, no + extra dependency) into a small node tree. Aliases are never expanded, so + an alias bomb anywhere costs nothing, and anchors / aliases / merge keys / + custom tags are refused only inside `patches` and `projectIgnorePaths` + (an anchor in `issueRules` keeps working). Duplicate keys and the depth + bound are enforced while building the tree. `serde_norway` was not + picked: its libyaml core expands aliases with only a global repetition + limit and cannot refuse them per subtree. Unit tests prove each property. +2. **Disk markers** are the in-memory engine's lock markers (plus the + Maven/NuGet markers disk scans support); manifests are not markers. + With `package.json` as a disk marker, `ignorePaths: ["**/package-lock.json"]` + would never exclude a disk root while excluding the same root in memory. +3. **Whole-file errors.** YAML syntax, duplicate keys, a non-mapping top + level, depth and a second document are errors even without a `patches` + block (the file cannot be known not to have one). `patches: null` counts + as present for the version gate; a key under `patches` with no value is an + error, never "default". +4. **`enabled: false`** reports recorded packages under `retained[]` + (`policy_disabled`) and every other candidate under `filtered[]`. +5. **Floor vs recorded patch** (until B's `search_result_supersedes`): a + recorded package keeps its recorded patch when it outranks every + floor-admitted patch in the canonical ranking, or when nothing passes the + floor; otherwise the admitted winner is selected, exactly as without a + floor. +6. **Retained packages** stay in the batch query (so `upgradeAvailable` can + be reported) but never reach selection or a writer. +7. **PATH-glob matches under a default ignore** are still visited as roots + and root-filtered (one `purl: null` entry), so a recorded patch there is + reported as retained. +8. **In-memory engine gaps until B lands its recorded view**: `retained[]` + is empty and the floor rule of item 5 cannot see recorded pins (filtered + packages' pins stay byte-identical regardless: the rewriters only touch + selected dependencies). `selectHostedScanPaths` applies the built-in + default ignores, so a socket.yml negation of a default cannot re-include + an in-memory root (the file's content is unknown at selection time); a + root named in `projectRoots` is explicit and skips the defaults. There + is no case-variant warning in memory (selection streams exact names + only). `ProjectResult.skipped[]` carries the post-lookup policy reasons + (severity, disabled); the pre-lookup ones are in the session `policy` + block only. +9. **Session option `policyPaths`** (selection's list, handed back like + `projectRoots`) is how the session tells "listed but never sent" from + absent. +10. **Env layer of `--min-severity`** is read by scan, not clap, so the + `policy` block can say `source: "env"`; a malformed env value exits 2 at + run time, a malformed flag at parse time. +11. **README recipes**: R2-R4 and R6 ship without `maxNewPatches` lines (A + validates the key but does not enforce the cap); R1 and R5 come with B. +12. **`get`'s `policy_bypassed`** is one warning per package; the severity + reason fires only when none of the package's patches passes the floor. + ## 10. Open questions (decided by default, revisit with evidence) - A separate upgrade cap (`maxUpgrades`) if server-side republishes rotate From faa6ffcc4df48b19561bae300d738091d2add5b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:56:07 +0000 Subject: [PATCH 09/14] Harden the socket.yml policy after review Fixes from an adversarial self-review of the policy work: - A non-ASCII package spec no longer panics validation, and error text, warnings and verbose lines drop terminal escapes and bidi or zero-width characters. - Ignore lists that fail to compile together are an error instead of silently matching nothing, and the policy file is opened by its resolved path without following a swapped-in symlink. - A top-level key that looks like a misspelled `patches` (`patchs`), a top-level merge key or an aliased key now fails closed. - Repo-root lookup follows a `.git` symlink and trusts the checkout owner under root and sudo, so CI containers keep the policy. - The severity floor always reports the patch it held back, report- only --json runs report what a floor or `enabled: false` hides, a root skipped as a whole is always one entry and no longer prints "No packages found", warnings print once per invocation, and the policy line is omitted when there is nothing to say. - policy entries are sorted and use canonical purls on disk and in memory; the in-memory engine applies a socket.yml negation of a built-in ignore to roots it was given, and policyError carries no CLI-only remedy. - A lockfile-less disk root matches path filters by its manifests. Tests cover each fix, plus the prune universe, agent path filters, a vendored package held byte-identical, and tighter oracles; docs are corrected where they overstated what the human output names. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 15 +- README.md | 7 +- crates/socket-patch-cli/CLI_CONTRACT.md | 13 +- .../socket-patch-cli/src/commands/scan/mod.rs | 41 ++++-- .../src/commands/scan/policy.rs | 109 +++++++++++---- .../socket-patch-cli/src/hosted_memory/mod.rs | 8 +- .../src/hosted_memory/roots.rs | 18 ++- .../socket-patch-cli/tests/cli_parse_scan.rs | 2 + .../tests/e2e_socket_yml_policy.rs | 105 +++++++++++++- .../tests/hosted_memory_common/mod.rs | 5 +- .../tests/hosted_memory_parity.rs | 25 ++++ crates/socket-patch-core/src/policy/mod.rs | 129 +++++++++++------- .../src/policy/socket_yml.rs | 114 +++++++++++++--- crates/socket-patch-core/src/policy/tests.rs | 30 ++-- docs/design/staged-rollout.md | 24 +++- 15 files changed, 500 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9907bd10..4a6db524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -573,9 +573,11 @@ into the new version's section — see docs/releasing.md. detected roots (which used to skip them through a hard-coded, case- sensitive segment list). A directory you name (`--cwd`, a literal PATH, `projectRoots`) is not affected; `ignorePaths: ["!/e2e/tests/"]` - re-includes one. -- **An invalid socket.yml fails scan.** An unparseable file, a misspelled or - invalid `patches` block (unknown key, wrong type, bad glob, `patches` + re-includes one (in memory only when the host streamed that root's + files: `selectHostedScanPaths` applies the defaults). +- **An invalid socket.yml fails scan.** An unparseable file, a misspelled + top-level `patches` key (`Patches`, `patchs`), a top-level merge or + aliased key, an invalid `patches` block (unknown key, wrong type, bad glob, `patches` without `version: 2`), or `socket.yml` and `socket.yaml` that disagree now fail `scan` before any request or write: exit 1, `errorCode: socket_yml_invalid` / `socket_yml_ambiguous`, the key path and the fix @@ -592,14 +594,17 @@ into the new version's section — see docs/releasing.md. `ecosystems`, `packages` / `ignorePackages` (`--package` specs), `minSeverity` (critical|high|medium|moderate|low, judged by the worst advisory a patch fixes) and `maxNewPatches` (validated; the per-run cap - lands with `--max-new-patches`). Flags only narrow further. A package + lands with `--max-new-patches`). List flags (`--ecosystems`, + `--package`, PATHs) only narrow further; `--min-severity` beats the + file's floor and `--no-socket-yml` ignores the file. A package that already carries a patch is never removed, upgraded or replaced by the policy: it is held and reported under `policy.retained[]`. New flags `--min-severity` / `SOCKET_MIN_SEVERITY` and `--no-socket-yml` / `SOCKET_NO_SOCKET_YML`; every successful `scan --json` result gains a top-level `policy` block (`source`, `sha256`, `minSeverity`, `filtered[]`, `retained[]`) and the human output a `Policy (socket.yml): …` line that - names every skipped critical/high patch. The in-memory engine takes + names every skipped project and every critical/high patch the severity + floor held back. The in-memory engine takes `noSocketYml` / `minSeverity` / `policyPaths`, `selectHostedScanPaths` returns `policyPaths`, and the result carries `policy` or `policyError`. `get` ignores the policy and warns `policy_bypassed`. diff --git a/README.md b/README.md index 6231ab44..dd8bbe44 100644 --- a/README.md +++ b/README.md @@ -747,6 +747,8 @@ patches: and `testdata/` directories are skipped by default when scan discovers projects (a directory glob such as `scan 'services/*'`); re-include one with a negation (`ignorePaths: ["!/e2e/tests/"]`). A directory you name yourself is always scanned. + (The autopatch bot's tree listing skips those directories before it reads + socket.yml, so there a negation cannot bring one back.) - `packages` / `ignorePackages` take `--package` specs; prefer purls (`pkg:npm/core`), because a bare name also matches other ecosystems and scoped packages (`core` matches `@babel/core`). @@ -757,8 +759,9 @@ patches: is written, with the key and the fix in the message — a typo never widens the rollout. `--no-socket-yml` ignores the file for one run. - `scan --json` reports what the policy did in a top-level `policy` block - (`jq '.policy.counts'`); the human output adds a `Policy (socket.yml): …` line and - always names skipped critical/high patches. `get` ignores the policy (explicit + (`jq '.policy.counts'`); the human output adds a `Policy (socket.yml): …` line that + names every skipped project and every critical/high patch the severity floor held + back (`--verbose` lists everything). `get` ignores the policy (explicit intent) and warns `policy_bypassed`. Every key: `enabled`, `includePaths`, `ignorePaths`, `ecosystems`, `packages`, diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index c258cade..25f41588 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -206,16 +206,16 @@ patches: **Paths.** Path lists are gitignore patterns with the npm `ignore` package's semantics (the backend's `projectIgnorePaths` matcher): case-insensitive, anchored at the repo root, a leading or middle `/` anchors, a bare name matches at any depth, a trailing `/` matches directories only, `!` negates, the last match wins, and a negation cannot re-include anything under an ignored directory (evaluation walks top-down). Backslash is gitignore's escape character, not a separator. Patterns with a `..` segment, a drive letter, a NUL byte, or over 1024 bytes are rejected. -- They are matched against a project root's **marker files**, repo-relative: the lockfiles in the root's directory (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock(b)`, `vlt-lock.json`, `rush.json`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt`, `*.py.lock`/`pylock*.toml`, `Cargo.lock`, `go.mod`, `go.sum`, `composer.lock`, `Gemfile.lock`, `gems.locked`, plus the Maven/NuGet markers). A root is ignored iff **every** marker is ignored; with `includePaths`, it is included iff **any** marker matches; a root with no marker is matched as its directory. So `/package-lock.json`, `**/yarn.lock` and `examples/**` mean what they mean to the scanner; `includePaths: ["/*", "!/*/"]` targets only the repo-root project. -- Evaluation order (one combined list): the **built-in defaults** `test/ tests/ fixtures/ __fixtures__/ testdata/`, then `projectIgnorePaths`, then `patches.ignorePaths`. Re-include a default with a negation (`ignorePaths: ["!/e2e/tests/"]`). The defaults apply only to **discovered** roots: hosted/vendored PATH-glob matches and roots the in-memory engine detects. A root you name — `--cwd`, a literal PATH, an in-memory `projectRoots` entry — skips them (the other lists still apply). `node_modules .git .socket .yarn vendor` stay structural excludes of in-memory root detection; no policy negates them. +- They are matched against a project root's **marker files**, repo-relative: the lockfiles in the root's directory (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock(b)`, `vlt-lock.json`, `rush.json`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt`, `*.py.lock`/`pylock*.toml`, `Cargo.lock`, `go.mod`, `go.sum`, `composer.lock`, `Gemfile.lock`, `gems.locked`, plus the Maven/NuGet markers). A root is ignored iff **every** marker is ignored; with `includePaths`, it is included iff **any** marker matches; a disk root with no lockfile uses its manifests (`package.json`, `pyproject.toml`, `setup.py`, `Cargo.toml`, `composer.json`, `Gemfile`, `pom.xml`, `build.gradle`) instead, and one with neither is matched as its directory. So `/package-lock.json`, `**/yarn.lock` and `examples/**` mean what they mean to the scanner; `includePaths: ["/*", "!/*/"]` targets only the repo-root project. +- Evaluation order (one combined list): the **built-in defaults** `test/ tests/ fixtures/ __fixtures__/ testdata/`, then `projectIgnorePaths`, then `patches.ignorePaths`. Re-include a default with a negation (`ignorePaths: ["!/e2e/tests/"]`). The defaults apply only to **discovered** roots: hosted/vendored PATH-glob matches and roots the in-memory engine detects. In memory a negation re-includes a root only if the host streamed its files: `selectHostedScanPaths` cannot read socket.yml, so it applies the defaults itself (name such a root in `projectRoots` instead). A root you name — `--cwd`, a literal PATH, an in-memory `projectRoots` entry — skips them (the other lists still apply). `node_modules .git .socket .yarn vendor` stay structural excludes of in-memory root detection; no policy negates them. - A workspace member that shares its root's lockfile is part of that root's project: exclude it with `ignorePackages`, not paths. - A hosted/vendored PATH that resolves outside the repository root is a usage error (exit 2): one policy per invocation. -**Lookup.** The repo root is the nearest ancestor of `--cwd` (inclusive) holding `.git` (a directory, or a file for worktrees and submodules), not walking past a `GIT_CEILING_DIRECTORIES` entry or into the home directory (unless `--cwd` is it), and, on Unix, only when `.git` belongs to the current user or root (otherwise warning `socket_yml_repo_untrusted` and `--cwd` is the root). No `.git`: the root is `--cwd`. Only `/socket.yml` and `/socket.yaml` are read, matched by exact directory-entry name (`Socket.yml` is not read: warning `socket_yml_name_case`); nested files never are. A symlinked file is followed only to a regular file inside the repo root. `--global` / `--global-prefix` scans read no file. +**Lookup.** The repo root is the nearest ancestor of `--cwd` (inclusive) holding `.git` (a directory, a file for worktrees and submodules, or a symlink to either), not walking past a `GIT_CEILING_DIRECTORIES` entry or into the home directory (unless `--cwd` is it), and, on Unix, only when `.git` belongs to the current user, to root, or to the user `sudo` ran for (`SUDO_UID`); a root process trusts every owner, since CI containers commonly run as root over a checkout owned by another uid (otherwise warning `socket_yml_repo_untrusted` and `--cwd` is the root). No `.git`: the root is `--cwd`. Only `/socket.yml` and `/socket.yaml` are read, matched by exact directory-entry name (`Socket.yml` is not read: warning `socket_yml_name_case`); nested files never are. A symlinked file is followed only to a regular file inside the repo root. `--global` / `--global-prefix` scans read no file. -**Validation (fail closed).** Because the file only narrows, a file that cannot be honored never means "no policy". Checked in order: file access (a regular file after resolving, at most 64 KiB, read from the opened handle), encoding (UTF-8; a BOM is stripped and CRLF is fine; UTF-16 and NUL bytes are errors), YAML 1.2 syntax (duplicate keys, a non-mapping top level, nesting deeper than 32 and a second document are errors), a top-level key equal to `patch`/`patches` ignoring case but not exactly `patches`, the version gate (`patches` requires `version: 2`), then the keys. Inside `patches` and `projectIgnorePaths`, anchors, aliases, merge keys (`<<`) and custom tags are errors (aliases elsewhere are never expanded). An unknown key under `patches` is an error with a did-you-mean hint and "a newer socket-patch may support it". Wrong types are errors — no coercion (`"false"` is not a bool; YAML 1.2, so `no` is a string) — as are an unknown severity, an out-of-range `maxNewPatches`, an invalid pattern or spec, a list over 1000 entries and an entry over 1024 bytes. Every error names the file and the key path (`patches.minSeverity`). `projectIgnorePaths` is validated strictly when a `patches` block exists (a single string is coerced to a one-element list); without one, a malformed value only warns `socket_yml_ignored_value` and is ignored, and it is honored whatever the `version`. An empty or comment-only file counts as no file. When both `socket.yml` and `socket.yaml` exist, both are validated; if their `projectIgnorePaths` and `patches` are equal as parsed values `socket.yml` is used, otherwise the run fails with `socket_yml_ambiguous`. +**Validation (fail closed).** Because the file only narrows, a file that cannot be honored never means "no policy". Checked in order: file access (a regular file after resolving, at most 64 KiB, read from the opened handle), encoding (UTF-8; a BOM is stripped and CRLF is fine; UTF-16 and NUL bytes are errors), YAML 1.2 syntax (duplicate keys, a non-mapping top level, nesting deeper than 32 and a second document are errors), a top-level key that looks like a misspelled `patches` (equal to `patch`/`patches` ignoring case, or within two edits of it and starting `pat`/`pac`, e.g. `patchs`), a top-level merge key (`<<`) or aliased key (either could carry a `patches` block other YAML readers apply), the version gate (`patches` requires `version: 2`), then the keys. Inside `patches` and `projectIgnorePaths`, anchors, aliases, merge keys (`<<`) and custom tags are errors (aliases elsewhere are never expanded). An unknown key under `patches` is an error with a did-you-mean hint and "a newer socket-patch may support it". Wrong types are errors — no coercion (`"false"` is not a bool; YAML 1.2, so `no` is a string) — as are an unknown severity, an out-of-range `maxNewPatches`, an invalid pattern or spec, a list over 1000 entries and an entry over 1024 bytes. Every error names the file and the key path (`patches.minSeverity`). `projectIgnorePaths` is validated strictly when a `patches` block exists (a single string is coerced to a one-element list); without one, a malformed value only warns `socket_yml_ignored_value` and is ignored, and it is honored whatever the `version`. An empty or comment-only file counts as no file. When both `socket.yml` and `socket.yaml` exist, both are validated; if their `projectIgnorePaths` and `patches` are equal as parsed values `socket.yml` is used, otherwise the run fails with `socket_yml_ambiguous`. -**Error output.** Before any request or write, `scan` exits **1** with scan's error object plus an additive `errorCode` (`socket_yml_invalid` or `socket_yml_ambiguous`): `{"status": "error", "error": "socket.yml: patches.minSeverty: unknown key … (fix the file, or pass --no-socket-yml to ignore it)", "errorCode": "socket_yml_invalid", …}` with every count at zero; no `policy` block. Human output: `Error (socket_yml_invalid): …` on stderr. The in-memory engine reports `policyError: {code, detail}` with no root processed and no file changed. +**Error output.** Before any request or write, `scan` exits **1** with scan's error object plus an additive `errorCode` (`socket_yml_invalid` or `socket_yml_ambiguous`): `{"status": "error", "error": "socket.yml: patches.minSeverty: unknown key … (fix the file, or pass --no-socket-yml to ignore it)", "errorCode": "socket_yml_invalid", …}` with every count at zero; no `policy` block. Human output: `Error (socket_yml_invalid): …` on stderr. The in-memory engine reports `policyError: {code, detail}` (the detail without the CLI remedy) with no root processed and no file changed. **The trust boundary holds.** No key names an endpoint, a credential, an org, a mode, a download format or a safety switch — such keys are unknown keys and fail validation. Every key only removes candidates or (`maxNewPatches`) delays them; none can add a package or bypass the tier filter, the agent partition, reference grants, containment checks or any refusal. @@ -253,7 +253,8 @@ patches: - Reason codes (stable): `policy_disabled`, `policy_path_excluded`, `policy_path_not_included`, `policy_ecosystem`, `policy_package_not_listed`, `policy_package_ignored`, `policy_severity` (detail `low < high`, `unknown < high`). In-memory `ProjectResult.skipped[]` carries the post-lookup ones (severity, disabled) with the same codes. - Every string copied from the file (patterns, specs, key names) is truncated to 200 characters with control characters stripped. - Warnings ride scan's top-level `warnings[]` (`{code, detail}`): `socket_yml_ignored_value`, `socket_yml_name_case`, `socket_yml_repo_untrusted`, `patches_disabled`. -- Human output: one line after the table, e.g. `Policy (socket.yml): 3 skipped by filters, 1 patched package held.`, then every filtered critical/high candidate by name (a policy must not hide those silently); `--verbose` lists every entry. +- Human output: one line after the table when anything was filtered or held, e.g. `Policy (socket.yml): 3 skipped by filters, 1 patched package held.`, then every skipped project and every critical/high patch the severity floor or `enabled: false` held back, by name (a policy must not hide those silently; path, ecosystem and package filters run before any patch lookup, so their severity is unknown); `--verbose` lists every entry. A report-only `--json` run (`--prune` or `--global` with no mode) fetches patch details only when a floor or `enabled: false` could withhold something, so its `filtered[]` matches the human output. +- `filtered[]` and `retained[]` are sorted by project, then purl; purls use the canonical spelling (qualifiers stripped, percent-decoded). - Exit code is unchanged by filtering. ### Embedded VEX (`apply --vex` / `scan --vex` / `vendor --vex`) diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index aecfacda..18198f93 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -1277,8 +1277,9 @@ async fn run_project_dirs( let resolved = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.clone()); if !resolved.starts_with(&invocation.repo_root) { eprintln!( - "Error: `{}` is outside the repository root {}: scan one repository per \ - invocation", + "Error: `{}` is outside {} (the repository root socket.yml is read \ + from; without a trusted .git it is --cwd): run one scan per repository, \ + or pass --cwd at a common parent", dir.display(), invocation.repo_root.display() ); @@ -1705,14 +1706,17 @@ async fn run_scan( print_json(&result); return code; } else if !args.common.silent { - println!( - "{}", - render::no_packages_message( - args.common.is_global(), - args.common.ecosystems.as_deref(), - &args.paths, - ) - ); + // A project the policy skipped as a whole is not an empty one. + if !policy.root_excluded() { + println!( + "{}", + render::no_packages_message( + args.common.is_global(), + args.common.ecosystems.as_deref(), + &args.paths, + ) + ); + } policy.print_human(args.common.silent, args.common.verbose); } return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; @@ -2041,6 +2045,23 @@ async fn run_scan( let dry = args.common.dry_run; let mut apply_code = 0i32; + // A report-only run selects nothing, but a severity floor or + // `enabled: false` still hides candidates; report them like the + // human arm does (the detail fetch runs only then). + if !apply && !vendor && policy.reports_selection() && !all_packages_with_patches.is_empty() { + let _ = discover_selected( + &api_client, + &all_packages_with_patches, + can_access_paid_patches, + &policy, + false, + false, + telemetry, + Some(&mut result), + ) + .await; + } + // --- Apply path (if requested) ----------------------------------- if apply { let selected: Vec = match discover_selected( diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 97dbc5de..79385fb5 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -39,6 +39,9 @@ pub(crate) struct InvocationPolicy { pub policy: SelectionPolicy, pub repo_root: PathBuf, pub warnings: Vec, + /// Set once the invocation's warnings were printed (a PATH list runs + /// one scan per directory; the file was read once). + pub warned: std::sync::atomic::AtomicBool, } /// Load the policy for `args` (4.5): `--global` scans have no repo and read @@ -54,6 +57,7 @@ pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result Result Vec { .collect() }) .unwrap_or_default(); + if markers.is_empty() { + // No lockfile: the manifests say what the project is. + markers = MANIFEST_MARKERS + .iter() + .filter(|name| dir.join(name).is_file()) + .map(|name| name.to_string()) + .collect(); + } markers.sort(); markers } +/// Manifests that stand in as markers for a root with no lockfile. +const MANIFEST_MARKERS: [&str; 8] = [ + "package.json", + "pyproject.toml", + "setup.py", + "Cargo.toml", + "composer.json", + "Gemfile", + "pom.xml", + "build.gradle", +]; + /// One `policy.filtered[]` entry. #[derive(Debug, Clone)] pub(crate) struct FilteredEntry { @@ -123,6 +148,11 @@ pub(crate) fn policy_block( _ => (serde_json::Value::Null, serde_json::Value::Null), }; let (floor, floor_source) = policy.min_severity(); + // Sorted: crawl order is filesystem order, and the two engines differ. + let mut filtered: Vec<&FilteredEntry> = filtered.iter().collect(); + filtered.sort_by(|a, b| (&a.project, &a.purl, a.reason.code()).cmp(&(&b.project, &b.purl, b.reason.code()))); + let mut retained: Vec<&RetainedEntry> = retained.iter().collect(); + retained.sort_by(|a, b| (&a.project, &a.purl).cmp(&(&b.project, &b.purl))); let filtered: Vec = filtered .iter() .map(|f| { @@ -178,6 +208,8 @@ struct Report { pub(crate) struct ScanPolicy { pub policy: SelectionPolicy, pub warnings: Vec, + /// Print [`Self::warnings`] on the human path (first root only). + announce_warnings: bool, /// Repo-relative root directory (`""` for the repo root). pub project: String, /// The root filter's verdict (`Ok` for global scans). @@ -211,16 +243,40 @@ impl ScanPolicy { .to_string(), }); } + let mut report = Report::default(); + // A root filtered as a whole is one entry, whatever it holds. + if let Err(reason) = &root_verdict { + report.filtered.push(FilteredEntry { + purl: None, + uuid: None, + project: project.clone(), + reason: reason.clone(), + severity: None, + }); + } + let announce_warnings = !invocation.warned.swap(true, std::sync::atomic::Ordering::Relaxed); Self { policy: invocation.policy.clone(), warnings, + announce_warnings, project, root_verdict, recorded: HashMap::new(), - report: Mutex::new(Report::default()), + report: Mutex::new(report), } } + /// Whether selection can filter anything (a floor, or patching + /// disabled): report-only runs select only for the report then. + pub(crate) fn reports_selection(&self) -> bool { + !self.policy.enabled() || self.policy.min_severity().0.is_some() + } + + /// Whether the policy filtered this whole project root. + pub(crate) fn root_excluded(&self) -> bool { + self.root_verdict.is_err() + } + fn report(&self) -> std::sync::MutexGuard<'_, Report> { self.report.lock().unwrap_or_else(|e| e.into_inner()) } @@ -271,15 +327,7 @@ impl ScanPolicy { return true; } if self.root_verdict.is_err() { - if !report.filtered.iter().any(|f| f.purl.is_none()) { - report.filtered.push(FilteredEntry { - purl: None, - uuid: None, - project: self.project.clone(), - reason, - severity: None, - }); - } + // Already reported as the root's one entry. } else if report.filtered_purls.insert(canon(purl)) { report.filtered.push(FilteredEntry { purl: Some(canon(purl)), @@ -341,7 +389,7 @@ impl ScanPolicy { } } None => report.filtered.push(FilteredEntry { - purl: Some(purl.clone()), + purl: Some(canon(&purl)), uuid: Some(group[0].uuid.clone()), project: self.project.clone(), severity: Some(patch_severity_order(&group[0])), @@ -362,21 +410,24 @@ impl ScanPolicy { (_, Some(w), _) => Some(w), // Nothing above the floor: a recorded package keeps its patch. (true, None, Some(r)) => Some(r), - (true, None, None) => None, - (false, None, _) => { + (_, None, _) => None, + }; + // What the floor hid is reported: the top-ranked patch it withheld + // when the package ends up unpatched or held at its recorded patch + // (not when a lower-ranked admitted patch simply wins). + let top_withheld = self.policy.admits_severity(patch_severity_order(&group[0])); + if let Err(reason) = top_withheld { + let upgrade_withheld = chosen.is_some() && chosen == recorded_at && recorded_at != Some(0); + if chosen.is_none() || upgrade_withheld { report.filtered.push(FilteredEntry { - purl: Some(purl.clone()), + purl: Some(canon(&purl)), uuid: Some(group[0].uuid.clone()), project: self.project.clone(), severity: Some(patch_severity_order(&group[0])), - reason: self - .policy - .admits_severity(patch_severity_order(&group[0])) - .expect_err("no offer passed the floor"), + reason, }); - None } - }; + } if let Some(i) = chosen { offers.selected.insert(purl.clone(), group[i].clone()); } @@ -425,7 +476,7 @@ impl ScanPolicy { /// Print the policy warnings (stderr) once, human path. pub(crate) fn print_warnings(&self, silent: bool) { - if silent { + if silent || !self.announce_warnings { return; } for w in &self.warnings { @@ -444,7 +495,7 @@ impl ScanPolicy { } let filtered = report.filtered.len(); let retained = report.retained.len(); - if filtered == 0 && retained == 0 && matches!(self.policy.source(), PolicySource::None) { + if filtered == 0 && retained == 0 && self.policy.enabled() { return; } let label = match self.policy.source() { @@ -461,13 +512,17 @@ impl ScanPolicy { line.push_str(" Patching is disabled (patches.enabled: false)."); } println!("{line}"); - for f in &report.filtered { + let mut entries: Vec<&FilteredEntry> = report.filtered.iter().collect(); + entries.sort_by(|a, b| (&a.project, &a.purl).cmp(&(&b.project, &b.purl))); + for f in entries { + // A skipped project and a withheld critical/high patch are always + // named; everything else only with --verbose. let severe = f.severity.is_some_and(|s| s <= 1); - if !(verbose || severe) { + if !(verbose || severe || f.purl.is_none()) { continue; } let what = match &f.purl { - Some(purl) => normalize_purl(purl).into_owned(), + Some(purl) => sanitize(&normalize_purl(purl)), None if self.project.is_empty() => "this project".to_string(), None => format!("project {}", sanitize(&self.project)), }; @@ -482,8 +537,8 @@ impl ScanPolicy { for r in &report.retained { println!( " held {} at {}: {}", - normalize_purl(&r.purl), - r.recorded_uuid, + sanitize(&normalize_purl(&r.purl)), + sanitize(&r.recorded_uuid), r.reason.detail() ); } diff --git a/crates/socket-patch-cli/src/hosted_memory/mod.rs b/crates/socket-patch-cli/src/hosted_memory/mod.rs index 1dce302f..77f80fdc 100644 --- a/crates/socket-patch-cli/src/hosted_memory/mod.rs +++ b/crates/socket-patch-cli/src/hosted_memory/mod.rs @@ -404,7 +404,7 @@ async fn engine( let root_list: Vec = match &options.project_roots { Some(roots) => roots.clone(), - None => roots::detect_roots(files.keys().map(String::as_str), ecosystems).0, + None => roots::detect_roots_with(files.keys().map(String::as_str), ecosystems, false).0, }; // The full policy (paths from the file too) judges every root before // the project limit; roots named in `projectRoots` are explicit. @@ -493,7 +493,7 @@ async fn engine( match policy.admits_purl(&purl) { Ok(()) => admitted.push(purl), Err(reason) => policy_filtered.push(FilteredEntry { - purl: Some(purl), + purl: Some(crate::commands::scan::policy::canon(&purl)), uuid: None, project: state.root.clone(), reason, @@ -825,7 +825,7 @@ fn policy_error_output( policy: None, policy_error: Some(PolicyErrorInfo { code: error.code().to_string(), - detail: error.to_string(), + detail: error.detail(), }), } } @@ -875,7 +875,7 @@ fn select_with_policy( detail: Some(reason.detail()), }); filtered.push(FilteredEntry { - purl: Some(purl), + purl: Some(crate::commands::scan::policy::canon(&purl)), uuid: Some(winner.uuid.clone()), project: root.to_string(), severity: Some(patch_severity_order(&winner)), diff --git a/crates/socket-patch-cli/src/hosted_memory/roots.rs b/crates/socket-patch-cli/src/hosted_memory/roots.rs index 91cac49b..02873cf6 100644 --- a/crates/socket-patch-cli/src/hosted_memory/roots.rs +++ b/crates/socket-patch-cli/src/hosted_memory/roots.rs @@ -124,10 +124,23 @@ fn allowed(ecosystems: Option<&[String]>, eco: &str) -> bool { ecosystems.is_none_or(|list| list.iter().any(|e| e == eco)) } -/// The detected roots (sorted) and the marker paths that did not make one. +/// The detected roots (sorted) and the marker paths that did not make one, +/// with the policy's built-in default ignores applied (path selection, +/// which cannot see socket.yml's content). pub(crate) fn detect_roots<'a>( paths: impl IntoIterator, ecosystems: Option<&[String]>, +) -> (Vec, Vec) { + detect_roots_with(paths, ecosystems, true) +} + +/// [`detect_roots`]; `apply_defaults: false` leaves the built-in default +/// ignores to the caller (the session applies the full policy, whose +/// negations can re-include a default-ignored root). +pub(crate) fn detect_roots_with<'a>( + paths: impl IntoIterator, + ecosystems: Option<&[String]>, + apply_defaults: bool, ) -> (Vec, Vec) { let mut ignored: Vec = Vec::new(); let mut markers: BTreeMap> = BTreeMap::new(); @@ -178,7 +191,8 @@ pub(crate) fn detect_roots<'a>( .flatten() .map(|p| split_path(p).1.to_string()) .collect(); - let default_ignored = socket_patch_core::policy::builtin_defaults() + let default_ignored = apply_defaults + && socket_patch_core::policy::builtin_defaults() .admits_root(&socket_patch_core::policy::Root { rel_dir: dir, markers: &marker_names, diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 9d591160..52300ac6 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -475,6 +475,8 @@ fn scan_json_empty_cwd_emits_updates_key() { // sub-object onto the hosted default path fails loudly. let bin = env!("CARGO_BIN_EXE_socket-patch"); let tmp = tempfile::tempdir().expect("tempdir"); + // Its own repo root, so no socket.yml above the temp dir leaks in. + std::fs::create_dir(tmp.path().join(".git")).expect(".git"); let mut cmd = std::process::Command::new(bin); cmd.args(["scan", "--json", "--cwd"]).arg(tmp.path()); // Strip *every* SOCKET_* override the child would otherwise inherit. diff --git a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs index 02e054b5..12e9fd1b 100644 --- a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -700,6 +700,35 @@ async fn recorded_merged_patch_below_a_new_floor_is_kept() { let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); assert_eq!(code, 0, "{doc:#}"); assert_eq!(repo.lock("services/web"), pinned, "the floor never replaces the recorded merged patch"); + assert_eq!(doc["policy"]["minSeverity"], json!({"value": "high", "source": "file"})); + assert_eq!(doc["policy"]["counts"]["filtered"], 0, "a kept recorded patch is not a skip: {:#}", doc["policy"]); + + // The floor is live: a fresh root with the same offers gets the + // floor-admitted patch, not the merged low one. + let fresh = Repo::new(Some("version: 2\npatches:\n minSeverity: high\n")); + let (code, doc) = scan_json(&fresh.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + let lock = fresh.lock("services/web"); + assert!(lock.contains(&P_ALPHA.hosted_url()), "{lock}"); + assert!(!lock.contains(P_ALPHA_MERGED_LOW.uuid), "{lock}"); +} + +#[tokio::test] +#[serial] +async fn floor_with_nothing_admitted_reports_the_withheld_patch() { + let server = MockServer::start().await; + mount_api(&server, vec![P_BETA]).await; + let repo = Repo::new(Some("version: 2\npatches:\n minSeverity: critical\n")); + let web = repo.dir("services/web"); + let lock = repo.lock("services/web"); + let (code, stdout, stderr) = scan(&web, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{stdout}\n{stderr}"); + assert_eq!(repo.lock("services/web"), lock); + assert!(stdout.contains("Policy (socket.yml): 1 skipped by filters"), "{stdout}"); + // Only critical/high are named without --verbose. + assert!(!stdout.contains("skipped beta"), "{stdout}"); + let (_, stdout, _) = scan(&web, &server.uri(), &["--verbose"], &[]); + assert!(stdout.contains("skipped pkg:npm/beta@1.0.0 (low): low < critical"), "{stdout}"); } #[tokio::test] @@ -712,7 +741,7 @@ async fn path_outside_the_repo_is_a_usage_error() { write_npm_root(&outside, &["alpha"]); let (code, _, stderr) = scan(&repo.dir("services"), &server.uri(), &["web", "../../elsewhere"], &[]); assert_eq!(code, 2, "{stderr}"); - assert!(stderr.contains("outside the repository root"), "{stderr}"); + assert!(stderr.contains("is outside") && stderr.contains("run one scan per repository"), "{stderr}"); } #[tokio::test] @@ -861,3 +890,77 @@ async fn get_bypasses_the_policy_with_a_warning() { assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!(!stdout.contains("policy_bypassed"), "{stdout}"); } + +#[tokio::test] +#[serial] +async fn agent_mode_honors_path_filters_and_keeps_the_prune_universe() { + let server = MockServer::start().await; + mount_api(&server, vec![P_ALPHA, P_BETA]).await; + let repo = Repo::new(None); + let web = repo.dir("services/web"); + let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent"], &[]); + assert_eq!(code, 0, "{doc:#}"); + let manifest_before = std::fs::read(web.join(".socket/manifest.json")).unwrap(); + let recorded: Value = serde_json::from_slice(&manifest_before).unwrap(); + assert_eq!(recorded["patches"].as_object().unwrap().len(), 2); + + // The root is excluded by path: nothing selected, and a --sync (agent + // + prune) still judges the full crawl, so no entry is pruned. + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n includePaths: [\"/services/legacy/\"]\n").unwrap(); + let (code, doc) = scan_json(&web, &server.uri(), &["--sync"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(std::fs::read(web.join(".socket/manifest.json")).unwrap(), manifest_before); + assert_eq!(doc["policy"]["filtered"][0]["purl"], Value::Null); + assert_eq!(doc["policy"]["filtered"][0]["reason"], "policy_path_not_included"); + assert_eq!(doc["policy"]["counts"]["retained"], 2, "{:#}", doc["policy"]); + assert_eq!(doc["gc"]["removed"].as_array().map_or(0, Vec::len), 0, "{:#}", doc["gc"]); + + // A narrower ecosystem list under --sync prunes nothing either. + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n ecosystems: [pypi]\n").unwrap(); + let (code, doc) = scan_json(&web, &server.uri(), &["--sync"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(std::fs::read(web.join(".socket/manifest.json")).unwrap(), manifest_before); + + // patches.enabled: false skips the GC entirely. + std::fs::remove_dir_all(web.join("node_modules/beta")).unwrap(); + let pkg_lock = repo.lock("services/web").replace("\"node_modules/beta\"", "\"node_modules/gone\""); + std::fs::write(web.join("package-lock.json"), pkg_lock).unwrap(); + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n enabled: false\n").unwrap(); + let (code, doc) = scan_json(&web, &server.uri(), &["--sync"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert!(doc.get("gc").is_none(), "{doc:#}"); + assert_eq!(std::fs::read(web.join(".socket/manifest.json")).unwrap(), manifest_before); +} + +#[tokio::test] +#[serial] +async fn narrowing_after_vendoring_leaves_the_vendored_package_byte_identical() { + let server = MockServer::start().await; + mount_api(&server, vec![P_ALPHA]).await; + let repo = Repo::new(None); + let web = repo.dir("services/web"); + // Vendor alpha for real (offline, from a seeded manifest + blob). + let before = compute_git_sha256_from_bytes(orig_index("alpha").as_bytes()); + let after = compute_git_sha256_from_bytes(patched_index("alpha").as_bytes()); + std::fs::create_dir_all(web.join(".socket/blobs")).unwrap(); + std::fs::write(web.join(".socket/blobs").join(&after), patched_index("alpha")).unwrap(); + let manifest = json!({"patches": {P_ALPHA.purl(): { + "uuid": P_ALPHA.uuid, "exportedAt": "2026-01-01T00:00:00Z", + "files": {"package/index.js": {"beforeHash": before, "afterHash": after}}, + "vulnerabilities": {}, "description": "d", "license": "MIT", "tier": "free" + }}}); + std::fs::write(web.join(".socket/manifest.json"), serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + let (code, stdout, stderr) = run_cli(&web, &["vendor", "--json", "--offline", "--cwd", web.to_str().unwrap()], &[]); + assert_eq!(code, 0, "vendor fixture: {stdout}\n{stderr}"); + assert!(repo.lock("services/web").contains(".socket/vendor/"), "vendored lock"); + let snapshot = repo.snapshot(); + + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n ignorePackages: [\"pkg:npm/alpha\"]\n").unwrap(); + let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "vendored"], &[]); + assert_eq!(code, 0, "{doc:#}"); + let mut after_scan = repo.snapshot(); + after_scan.remove("socket.yml"); + assert_eq!(after_scan, snapshot, "the vendored package, its lock wiring and ledger stay byte-identical"); + assert_eq!(doc["policy"]["retained"][0]["purl"], "pkg:npm/alpha@1.0.0", "{:#}", doc["policy"]); +} + diff --git a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs index 72ba3b97..1e78e102 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs @@ -331,9 +331,8 @@ pub fn run_disk_in( std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, bytes).unwrap(); } - if !cwd_rel.is_empty() { - std::fs::create_dir_all(checkout.path().join(".git")).unwrap(); - } + // The checkout is its own repo: no socket.yml above the temp dir applies. + std::fs::create_dir_all(checkout.path().join(".git")).unwrap(); let cwd = checkout.path().join(cwd_rel); let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); cmd.env_clear(); diff --git a/crates/socket-patch-cli/tests/hosted_memory_parity.rs b/crates/socket-patch-cli/tests/hosted_memory_parity.rs index c26764f0..b3027ffa 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -927,3 +927,28 @@ fn selection_streams_policy_files_and_applies_built_in_ignores() { ); assert_eq!(named.roots, vec!["apps/web/tests/app"]); } + +#[tokio::test] +async fn memory_negation_reincludes_a_default_ignored_root_it_was_given() { + let npm = fixtures_root().join("redirect/npm/package-lock-v3/basic"); + let patches = patches_from_overrides(&npm.join("overrides.json"), None); + let server = MockServer::start().await; + mount_api(&server, &patches).await; + let mut repo: BTreeMap> = BTreeMap::new(); + for root in ["e2e/tests", "x/tests"] { + for (rel, bytes) in fixture_files(&npm.join("input")) { + repo.insert(format!("{root}/{rel}"), bytes); + } + } + repo.insert( + "socket.yml".to_string(), + b"version: 2\npatches:\n ignorePaths: [\"!/e2e/tests/\"]\n".to_vec(), + ); + let memory = run_engine(&server, build_input(&repo, &[], policy_options())).await; + let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); + assert_eq!(roots, vec!["e2e/tests"]); + let filtered = filtered_set(memory.policy.as_ref().unwrap()); + assert!(filtered.contains(&("x/tests".to_string(), None, "policy_path_excluded".to_string()))); + let entry = &memory.policy.as_ref().unwrap()["filtered"][0]; + assert_eq!(entry["detail"], "tests/ (built-in default)"); +} diff --git a/crates/socket-patch-core/src/policy/mod.rs b/crates/socket-patch-core/src/policy/mod.rs index f60a2472..49e7113f 100644 --- a/crates/socket-patch-core/src/policy/mod.rs +++ b/crates/socket-patch-core/src/policy/mod.rs @@ -47,11 +47,24 @@ pub const POLICY_BYPASSED: &str = "policy_bypassed"; /// Longest file-derived string copied into output. const MAX_OUTPUT_CHARS: usize = 200; -/// Make a file-derived string safe to print: control characters dropped, -/// at most 200 characters. +/// Characters never copied into output: controls (terminal escapes) and +/// the invisible formatting characters that can reorder or hide text +/// (bidi overrides and isolates, zero-width characters, BOM). +fn unsafe_for_output(c: char) -> bool { + c.is_control() + || matches!(c, '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{2069}' | '\u{FEFF}') +} + +/// [`sanitize`] without the length cap, for whole messages. +pub fn strip_unsafe(s: &str) -> String { + s.chars().filter(|c| !unsafe_for_output(*c)).collect() +} + +/// Make a file-derived string safe to print: control and invisible +/// formatting characters dropped, at most 200 characters. pub fn sanitize(s: &str) -> String { s.chars() - .filter(|c| !c.is_control()) + .filter(|c| !unsafe_for_output(*c)) .take(MAX_OUTPUT_CHARS) .collect() } @@ -154,8 +167,12 @@ impl PolicyError { /// The message without the remedy. pub fn detail(&self) -> String { match self { - PolicyError::Invalid { file, key, message } if key.is_empty() => format!("{file}: {message}"), - PolicyError::Invalid { file, key, message } => format!("{file}: {key}: {message}"), + PolicyError::Invalid { file, key, message } if key.is_empty() => { + format!("{file}: {}", strip_unsafe(message)) + } + PolicyError::Invalid { file, key, message } => { + format!("{file}: {}: {}", sanitize(key), strip_unsafe(message)) + } PolicyError::Ambiguous { files } => format!( "{} and {} both exist and their `patches`/`projectIgnorePaths` differ; keep one file", files[0], files[1] @@ -223,10 +240,10 @@ impl DiskPolicyFs { fn open_nonblocking(path: &Path) -> std::io::Result { use std::os::unix::fs::OpenOptionsExt; // O_NONBLOCK: opening a FIFO must not wait for a writer; the handle's - // metadata then refuses it. + // metadata then refuses it. O_NOFOLLOW: the path was resolved already. std::fs::OpenOptions::new() .read(true) - .custom_flags(libc::O_NONBLOCK) + .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW) .open(path) } @@ -244,16 +261,15 @@ impl PolicyFs for DiskPolicyFs { if !self.entry_names().iter().any(|n| n == name) { return Ok(RootFile::Absent); } - let path = self.root.join(name); - let link_meta = std::fs::symlink_metadata(&path)?; - if link_meta.file_type().is_symlink() { - let target = std::fs::canonicalize(&path)?; - let root = std::fs::canonicalize(&self.root)?; - if !target.starts_with(&root) { - return Err(io_other("symlink resolves outside the repository root")); - } + // Resolve first, confine, then open the resolved path without + // following a final symlink: a link swapped in after the check is + // refused instead of followed out of the repository. + let root = std::fs::canonicalize(&self.root)?; + let target = std::fs::canonicalize(self.root.join(name))?; + if !target.starts_with(&root) { + return Err(io_other("symlink resolves outside the repository root")); } - let file = open_nonblocking(&path)?; + let file = open_nonblocking(&target)?; let meta = file.metadata()?; if !meta.is_file() { return Err(io_other("not a regular file")); @@ -424,12 +440,15 @@ fn defaults_list() -> Vec { DEFAULT_IGNORE_PATHS.iter().map(|s| s.to_string()).collect() } -fn compile(lists: &[(&'static str, &[String])]) -> PathMatcher { - // Every list was compiled once during validation, so this cannot fail; - // an empty matcher would only ever admit more, which the validation - // already ruled out. - PathMatcher::new(lists) - .unwrap_or_else(|_| PathMatcher::new(&[]).expect("an empty pattern list always compiles")) +/// Compile a combined list. Each list was validated on its own, but the +/// combination can still exceed the glob engine's size limit; that is an +/// error (fail closed), never an empty matcher. +fn compile(file: &str, lists: &[(&'static str, &[String])]) -> Result { + PathMatcher::new(lists).map_err(|(list, _, message)| PolicyError::Invalid { + file: file.to_string(), + key: list.to_string(), + message: format!("the path patterns cannot be compiled together: {message}"), + }) } /// The built-in defaults, compiled once (for callers that only need the @@ -442,29 +461,37 @@ pub fn builtin_defaults() -> &'static SelectionPolicy { impl SelectionPolicy { /// No file: only the built-in default ignores. pub fn unrestricted() -> Self { - Self::from_parts(PolicySource::None, &[], &PatchesBlock::default()) + Self::from_parts("", PolicySource::None, &[], &PatchesBlock::default()) + .expect("the built-in default ignores always compile") } fn from_parts( + file: &str, source: PolicySource, project_ignore_paths: &[String], block: &PatchesBlock, - ) -> Self { + ) -> Result { let defaults = defaults_list(); - let ignore_discovered = compile(&[ - (DEFAULT_IGNORE_LIST, &defaults), - ("projectIgnorePaths", project_ignore_paths), - ("patches.ignorePaths", &block.ignore_paths), - ]); - let ignore_explicit = compile(&[ - ("projectIgnorePaths", project_ignore_paths), - ("patches.ignorePaths", &block.ignore_paths), - ]); - let include = block - .include_paths - .as_ref() - .map(|list| compile(&[("patches.includePaths", list)])); - Self { + let ignore_discovered = compile( + file, + &[ + (DEFAULT_IGNORE_LIST, &defaults), + ("projectIgnorePaths", project_ignore_paths), + ("patches.ignorePaths", &block.ignore_paths), + ], + )?; + let ignore_explicit = compile( + file, + &[ + ("projectIgnorePaths", project_ignore_paths), + ("patches.ignorePaths", &block.ignore_paths), + ], + )?; + let include = match block.include_paths.as_ref() { + Some(list) => Some(compile(file, &[("patches.includePaths", list)])?), + None => None, + }; + Ok(Self { source, enabled: block.enabled.unwrap_or(true), ignore_discovered, @@ -480,7 +507,7 @@ impl SelectionPolicy { SeveritySource::Default }, max_new_patches: block.max_new_patches, - } + }) } fn apply_overrides(&mut self, overrides: &PolicyOverrides) { @@ -500,8 +527,8 @@ impl SelectionPolicy { ) -> Result<(Self, Vec), PolicyError> { let mut warnings = Vec::new(); if overrides.bypass { - let mut policy = - Self::from_parts(PolicySource::Bypassed, &[], &PatchesBlock::default()); + let mut policy = Self::unrestricted(); + policy.source = PolicySource::Bypassed; policy.apply_overrides(overrides); return Ok((policy, warnings)); } @@ -553,7 +580,7 @@ impl SelectionPolicy { sha256: hex::encode(Sha256::digest(&bytes)), }; let block = parsed.patches.clone().unwrap_or_default(); - Self::from_parts(source, &parsed.project_ignore_paths, &block) + Self::from_parts(name, source, &parsed.project_ignore_paths, &block)? } _ => Self::unrestricted(), }; @@ -771,13 +798,19 @@ fn ceiling_dirs() -> Vec { #[cfg(unix)] fn trusted_owner(meta: &std::fs::Metadata) -> bool { use std::os::unix::fs::MetadataExt; + let sudo_uid = std::env::var("SUDO_UID").ok().and_then(|v| v.trim().parse::().ok()); // SAFETY: geteuid has no preconditions and cannot fail. - owner_trusted(meta.uid(), unsafe { libc::geteuid() }) + owner_trusted(meta.uid(), unsafe { libc::geteuid() }, sudo_uid) } +/// `.git` is trusted when it belongs to the invoking user, to root, or +/// (under sudo) to the user sudo ran for. Root trusts every owner: a root +/// process is exposed to the whole filesystem anyway, and CI containers +/// commonly run as root over a checkout owned by another uid, where +/// distrust would silently drop the repo's policy (which only narrows). #[cfg(unix)] -fn owner_trusted(owner: u32, euid: u32) -> bool { - owner == euid || owner == 0 +fn owner_trusted(owner: u32, euid: u32, sudo_uid: Option) -> bool { + euid == 0 || owner == euid || owner == 0 || sudo_uid == Some(owner) } #[cfg(not(unix))] @@ -788,7 +821,8 @@ fn trusted_owner(_meta: &std::fs::Metadata) -> bool { /// The repo root for `cwd` (4.5) with the lookup's warnings: the nearest /// ancestor (inclusive) holding a `.git` directory or file, not walking /// past `GIT_CEILING_DIRECTORIES` or into the home directory, and (Unix) -/// only when `.git` belongs to the current user or root. Otherwise `cwd`. +/// only when `.git` belongs to a trusted owner ([`owner_trusted`]). +/// Otherwise `cwd`. pub fn find_repo_root_with_warnings(cwd: &Path) -> (PathBuf, Vec) { let cwd = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); let ceilings = ceiling_dirs(); @@ -799,7 +833,8 @@ pub fn find_repo_root_with_warnings(cwd: &Path) -> (PathBuf, Vec) if dir != cwd && home.as_deref() == Some(dir) { break; } - if let Ok(meta) = std::fs::symlink_metadata(dir.join(".git")) { + // `metadata` follows a `.git` symlink, as git does. + if let Ok(meta) = std::fs::metadata(dir.join(".git")) { if meta.is_dir() || meta.is_file() { if trusted_owner(&meta) { return (dir.to_path_buf(), warnings); diff --git a/crates/socket-patch-core/src/policy/socket_yml.rs b/crates/socket-patch-core/src/policy/socket_yml.rs index b434240d..30a99499 100644 --- a/crates/socket-patch-core/src/policy/socket_yml.rs +++ b/crates/socket-patch-core/src/policy/socket_yml.rs @@ -384,8 +384,8 @@ impl Ctx<'_> { fn err(&self, key: impl Into, message: impl Into) -> PolicyError { PolicyError::Invalid { file: self.file.to_string(), - key: key.into(), - message: message.into(), + key: sanitize(&key.into()), + message: super::strip_unsafe(&message.into()), } } } @@ -486,8 +486,7 @@ pub(crate) fn package_spec_error(spec: &str) -> Option<&'static str> { if spec.is_empty() { return Some("package spec is empty"); } - if spec.len() >= 4 && spec[..4].eq_ignore_ascii_case("pkg:") { - let rest = &spec[4..]; + if let Some(rest) = spec.get(..4).filter(|p| p.eq_ignore_ascii_case("pkg:")).map(|_| &spec[4..]) { let valid = rest.split_once('/').is_some_and(|(ty, name)| { !ty.is_empty() && !name.trim_matches('/').is_empty() && !name.starts_with('@') }); @@ -753,9 +752,19 @@ pub(crate) fn parse_file( let mut patches: Option<&Node> = None; let mut ignore_paths: Option<&Node> = None; for (key, value) in pairs { + // A merge key or an aliased key could carry a `patches` block that + // other YAML readers apply; refuse rather than read "no policy". + if key.is_merge_key() || matches!(key.kind, Kind::Alias) || key.anchored { + return Err(ctx.err( + "", + "top-level merge keys (`<<`) and aliased keys are not supported; write the keys out", + )); + } let Some(name) = key.as_str() else { continue }; let lower = name.to_ascii_lowercase(); - if (lower == "patch" || lower == "patches") && name != "patches" { + let near_patches = (lower.starts_with("pat") || lower.starts_with("pac")) + && edit_distance(&lower, "patches") <= 2; + if (lower == "patch" || lower == "patches" || near_patches) && name != "patches" { return Err(ctx.err( sanitize(name), "looks like a misspelled `patches` block; the key must be exactly `patches`", @@ -776,7 +785,7 @@ pub(crate) fn parse_file( Some(Err((key, message))) => { warnings.push(PolicyWarning { code: super::SOCKET_YML_IGNORED_VALUE, - detail: format!("{file}: {key} {message}; the key is ignored"), + detail: super::strip_unsafe(&format!("{file}: {key} {message}; the key is ignored")), }); Vec::new() } @@ -887,19 +896,82 @@ mod tests { #[test] fn encoding_errors() { - for bytes in [ - &b"\xFF\xFEv\0e\0r\0"[..], - &b"\xFE\xFF\0v\0e"[..], - &b"version: 2\0\n"[..], - &b"version: \xC3\x28\n"[..], + for (bytes, expected) in [ + (&b"\xFF\xFEv\0e\0r\0"[..], "UTF-16"), + (&b"\xFE\xFF\0v\0e"[..], "UTF-16"), + (&b"version: 2\0\n"[..], "NUL"), + (&b"version: \xC3\x28\n"[..], "not valid UTF-8"), ] { - assert!( - parse_file("socket.yml", bytes, &mut Vec::new()).is_err(), - "{bytes:?}" - ); + match parse_file("socket.yml", bytes, &mut Vec::new()) { + Err(PolicyError::Invalid { message, .. }) => { + assert!(message.contains(expected), "{message}") + } + other => panic!("{bytes:?}: {other:?}"), + } } } + #[test] + fn checks_run_in_order() { + // YAML beats everything; the case variant beats the version gate; + // the version gate beats the keys. + assert_eq!(err_key("patches: {minSeverty: x}\n").0, "version"); + assert!(err_key("Patches: {}\npatches: {minSeverty: x}\n").1.contains("misspelled")); + assert!(err_key("patches: {minSeverty: x\n").1.contains("invalid YAML")); + } + + #[test] + fn top_level_typos_merge_keys_and_aliases_fail_closed() { + for text in [ + "version: 2\npatchs: {enabled: false}\n", + "version: 2\npacthes: {}\n", + "version: 2\npatches_: {}\n", + ] { + assert!(err_key(text).1.contains("misspelled"), "{text:?}"); + } + for text in [ + "base: &b {patches: {enabled: false}}\n<<: *b\nversion: 2\n", + "k: &k patches\nversion: 2\n*k : {enabled: false}\n", + ] { + assert!(err_key(text).1.contains("merge keys"), "{text:?}"); + } + // Unrelated scanner keys are fine. + assert!(parse( + "version: 2\ntriggerPaths: [a]\nissueRules: {x: true}\ngithubApp: {enabled: true}\n" + ) + .is_ok()); + } + + #[test] + fn non_ascii_specs_and_escapes_never_panic_or_leak() { + for spec in ["abc\u{e9}", "ab\u{20ac}", "p\u{e9}g:npm/x", "\u{1F600}"] { + let text = format!("version: 2\npatches:\n ignorePackages: [\"{spec}\"]\n"); + assert!(parse(&text).is_ok(), "{spec:?}"); + } + let text = "version: 2\npatches:\n ignorePaths: [\"\\e]0;pwned\\a\\e[2J[x\u{202E}\"]\n"; + let err = parse(text).unwrap_err(); + let shown = err.to_string(); + assert!( + !shown.chars().any(|c| c.is_control() || c == '\u{202E}'), + "{shown:?}" + ); + } + + #[test] + fn nesting_limit_boundary() { + // The top-level mapping is level 1: 31 nested lists reach 32 levels. + let at_limit = format!("a: {}{}\n", "[".repeat(31), "]".repeat(31)); + assert!(parse(&at_limit).is_ok()); + let over = format!("a: {}{}\n", "[".repeat(32), "]".repeat(32)); + assert!(err_key(&over).1.contains("deeper than 32")); + } + + #[test] + fn project_ignore_paths_string_without_patches_block() { + let parsed = parse("version: 2\nprojectIgnorePaths: \"examples/**\"\n").unwrap(); + assert_eq!(parsed.project_ignore_paths, vec!["examples/**".to_string()]); + } + #[test] fn yaml_errors() { for text in [ @@ -911,7 +983,17 @@ mod tests { "a: 1\n---\nb: 2\n", "projectIgnorePaths:\n - **\n", ] { - assert!(parse(text).is_err(), "{text:?} must fail"); + let (key, message) = err_key(text); + assert_eq!(key, "", "{text:?}"); + assert!( + [ + "invalid YAML", + "top level must be a mapping", + ] + .iter() + .any(|m| message.contains(m)), + "{text:?}: {message}" + ); } } diff --git a/crates/socket-patch-core/src/policy/tests.rs b/crates/socket-patch-core/src/policy/tests.rs index b3a75a36..fd824749 100644 --- a/crates/socket-patch-core/src/policy/tests.rs +++ b/crates/socket-patch-core/src/policy/tests.rs @@ -558,31 +558,23 @@ mod disk { #[cfg(unix)] #[test] fn owner_rule() { - assert!(owner_trusted(1000, 1000)); - assert!(owner_trusted(0, 1000)); - assert!(!owner_trusted(1001, 1000)); + assert!(owner_trusted(1000, 1000, None)); + assert!(owner_trusted(0, 1000, None)); + assert!(!owner_trusted(1001, 1000, None)); + assert!(owner_trusted(1001, 1000, Some(1001)), "sudo's invoking user"); + assert!(owner_trusted(1001, 0, None), "root trusts every owner"); } #[cfg(unix)] #[test] - fn foreign_owned_git_stops_the_walk() { - // SAFETY: no preconditions. - if unsafe { libc::geteuid() } != 0 { - // Only root can hand `.git` to another owner; `owner_rule` - // covers the decision itself. - return; - } + fn symlinked_git_marks_the_repo_root() { let tmp = tempfile::tempdir().unwrap(); let base = fs::canonicalize(tmp.path()).unwrap(); - fs::create_dir_all(base.join(".git")).unwrap(); - let cwd = base.join("sub"); - fs::create_dir_all(&cwd).unwrap(); - let git = std::ffi::CString::new(base.join(".git").to_str().unwrap()).unwrap(); - // SAFETY: a valid NUL-terminated path. - assert_eq!(unsafe { libc::chown(git.as_ptr(), 4242, 4242) }, 0); - let (found, warnings) = find_repo_root_with_warnings(&cwd); - assert_eq!(found, cwd); - assert_eq!(warnings[0].code, "socket_yml_repo_untrusted"); + fs::create_dir_all(base.join("gitdir")).unwrap(); + let repo = base.join("repo"); + fs::create_dir_all(repo.join("sub")).unwrap(); + std::os::unix::fs::symlink(base.join("gitdir"), repo.join(".git")).unwrap(); + assert_eq!(find_repo_root(&repo.join("sub")), repo); } } diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index 1bb12aff..4395f013 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -1054,9 +1054,11 @@ Gaps and contradictions A resolved with the smallest reasonable decision is empty and the floor rule of item 5 cannot see recorded pins (filtered packages' pins stay byte-identical regardless: the rewriters only touch selected dependencies). `selectHostedScanPaths` applies the built-in - default ignores, so a socket.yml negation of a default cannot re-include - an in-memory root (the file's content is unknown at selection time); a - root named in `projectRoots` is explicit and skips the defaults. There + default ignores (the file's content is unknown at selection time), so a + socket.yml negation re-includes an in-memory root only when the host + streamed its files anyway; the session itself detects roots without the + defaults and applies the full policy. A root named in `projectRoots` is + explicit and skips the defaults. There is no case-variant warning in memory (selection streams exact names only). `ProjectResult.skipped[]` carries the post-lookup policy reasons (severity, disabled); the pre-lookup ones are in the session `policy` @@ -1071,6 +1073,22 @@ Gaps and contradictions A resolved with the smallest reasonable decision validates the key but does not enforce the cap); R1 and R5 come with B. 12. **`get`'s `policy_bypassed`** is one warning per package; the severity reason fires only when none of the package's patches passes the floor. +13. **Repo-root trust** (review follow-up): root trusts every `.git` owner + and `SUDO_UID`'s user is trusted, so a root CI container over a checkout + owned by another uid still applies the policy (distrust would drop a + policy that only narrows). A `.git` symlink counts, as in git. +14. **A disk root with no lockfile** uses its manifests as markers, so + `includePaths: ["/*", "!/*/"]` can match a lockfile-less repo root. +15. **Top-level typos and merge keys fail closed**: a key within two edits + of `patches` starting `pat`/`pac`, and a top-level `<<` or aliased key, + are errors (another YAML reader could see a `patches` block there). +16. **Report-only `--json`** fetches patch details only when a floor or + `enabled: false` could withhold something, so its `filtered[]` matches + the human output. +17. **What the floor reports**: the top-ranked patch it withheld, when the + package ends up unpatched or held at its recorded patch; not when a + lower-ranked admitted patch wins. A kept recorded patch that is itself + below the floor is not a skip. ## 10. Open questions (decided by default, revisit with evidence) From b975913f3b9b05cde5d77d48a8c30c9c858c54fb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:41:37 +0000 Subject: [PATCH 10/14] Give the release test job more time The optimized test job now has to build two more crates and one more test binary for the socket.yml policy. It ran out of time on its last 40 minutes, about a minute short, and a docs-only change already takes 38. Raise the limit to 50 minutes so it can finish. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb2d4722..d102b932 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -340,8 +340,9 @@ jobs: # default features): ~25m on main with ~240 test binaries, so 30m left # no headroom as suites grow. The manifest-less VEX suites share two # multi-module binaries (tests/e2e_vex_lockfile/, tests/e2e_vex_build/) - # to keep the count down; the extra 10m covers the rest. - timeout-minutes: 40 + # to keep the count down. With no cache on PR runs the job takes ~38m + # before any new crate or suite, so 50m keeps headroom. + timeout-minutes: 50 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From e1a35fe66fafb9eec98015558a22e1178038e7a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:49:57 +0000 Subject: [PATCH 11/14] Apply socket.yml paths during memory selection The merged plan makes in-memory path selection two-phase: the host fetches the root socket.yml first and passes its text to selectHostedScanPaths. Selection now applies the full path policy, so a negation such as `!/e2e/tests/` brings a test tree back in memory exactly as on disk. A listed policy file that is missing, symlinked or invalid returns policyError with nothing selected. Selection returns policySha256, and the session fails closed when the policy it reads differs. Roots the policy excludes keep their marker files presence-only, so the session still lists them as filtered. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 12 +- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/commands/hosted_bundle.rs | 3 + .../src/hosted_memory/limits.rs | 2 + .../socket-patch-cli/src/hosted_memory/mod.rs | 41 +++- .../src/hosted_memory/roots.rs | 92 ++------- .../src/hosted_memory/select.rs | 138 +++++++++++-- .../src/hosted_memory/types.rs | 29 +++ .../tests/hosted_memory_parity.rs | 192 +++++++++++++++--- crates/socket-patch-node/npm/index.d.ts | 7 +- docs/design/staged-rollout.md | 20 +- 11 files changed, 400 insertions(+), 140 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6db524..3c79eecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -573,8 +573,7 @@ into the new version's section — see docs/releasing.md. detected roots (which used to skip them through a hard-coded, case- sensitive segment list). A directory you name (`--cwd`, a literal PATH, `projectRoots`) is not affected; `ignorePaths: ["!/e2e/tests/"]` - re-includes one (in memory only when the host streamed that root's - files: `selectHostedScanPaths` applies the defaults). + re-includes one, in memory too. - **An invalid socket.yml fails scan.** An unparseable file, a misspelled top-level `patches` key (`Patches`, `patchs`), a top-level merge or aliased key, an invalid `patches` block (unknown key, wrong type, bad glob, `patches` @@ -604,9 +603,12 @@ into the new version's section — see docs/releasing.md. top-level `policy` block (`source`, `sha256`, `minSeverity`, `filtered[]`, `retained[]`) and the human output a `Policy (socket.yml): …` line that names every skipped project and every critical/high patch the severity - floor held back. The in-memory engine takes - `noSocketYml` / `minSeverity` / `policyPaths`, `selectHostedScanPaths` - returns `policyPaths`, and the result carries `policy` or `policyError`. + floor held back. In memory, selection is two-phase: + `selectHostedScanPaths` takes the root policy files' text + (`policyFiles`) and `noSocketYml`, applies the full path policy and + returns `policyPaths`, `policySha256` and `policyError`; the session + takes `noSocketYml` / `minSeverity` / `policyPaths` / `policySha256` and + its result carries `policy` or `policyError`. `get` ignores the policy and warns `policy_bypassed`. - **`scan --package `** (repeatable or comma-separated, env diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 25f41588..8a24c086 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -207,12 +207,14 @@ patches: **Paths.** Path lists are gitignore patterns with the npm `ignore` package's semantics (the backend's `projectIgnorePaths` matcher): case-insensitive, anchored at the repo root, a leading or middle `/` anchors, a bare name matches at any depth, a trailing `/` matches directories only, `!` negates, the last match wins, and a negation cannot re-include anything under an ignored directory (evaluation walks top-down). Backslash is gitignore's escape character, not a separator. Patterns with a `..` segment, a drive letter, a NUL byte, or over 1024 bytes are rejected. - They are matched against a project root's **marker files**, repo-relative: the lockfiles in the root's directory (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock(b)`, `vlt-lock.json`, `rush.json`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt`, `*.py.lock`/`pylock*.toml`, `Cargo.lock`, `go.mod`, `go.sum`, `composer.lock`, `Gemfile.lock`, `gems.locked`, plus the Maven/NuGet markers). A root is ignored iff **every** marker is ignored; with `includePaths`, it is included iff **any** marker matches; a disk root with no lockfile uses its manifests (`package.json`, `pyproject.toml`, `setup.py`, `Cargo.toml`, `composer.json`, `Gemfile`, `pom.xml`, `build.gradle`) instead, and one with neither is matched as its directory. So `/package-lock.json`, `**/yarn.lock` and `examples/**` mean what they mean to the scanner; `includePaths: ["/*", "!/*/"]` targets only the repo-root project. -- Evaluation order (one combined list): the **built-in defaults** `test/ tests/ fixtures/ __fixtures__/ testdata/`, then `projectIgnorePaths`, then `patches.ignorePaths`. Re-include a default with a negation (`ignorePaths: ["!/e2e/tests/"]`). The defaults apply only to **discovered** roots: hosted/vendored PATH-glob matches and roots the in-memory engine detects. In memory a negation re-includes a root only if the host streamed its files: `selectHostedScanPaths` cannot read socket.yml, so it applies the defaults itself (name such a root in `projectRoots` instead). A root you name — `--cwd`, a literal PATH, an in-memory `projectRoots` entry — skips them (the other lists still apply). `node_modules .git .socket .yarn vendor` stay structural excludes of in-memory root detection; no policy negates them. +- Evaluation order (one combined list): the **built-in defaults** `test/ tests/ fixtures/ __fixtures__/ testdata/`, then `projectIgnorePaths`, then `patches.ignorePaths`. Re-include a default with a negation (`ignorePaths: ["!/e2e/tests/"]`). The defaults apply only to **discovered** roots: hosted/vendored PATH-glob matches and roots the in-memory engine detects. A root you name — `--cwd`, a literal PATH, an in-memory `projectRoots` entry — skips them (the other lists still apply). `node_modules .git .socket .yarn vendor` stay structural excludes of in-memory root detection; no policy negates them. - A workspace member that shares its root's lockfile is part of that root's project: exclude it with `ignorePackages`, not paths. - A hosted/vendored PATH that resolves outside the repository root is a usage error (exit 2): one policy per invocation. **Lookup.** The repo root is the nearest ancestor of `--cwd` (inclusive) holding `.git` (a directory, a file for worktrees and submodules, or a symlink to either), not walking past a `GIT_CEILING_DIRECTORIES` entry or into the home directory (unless `--cwd` is it), and, on Unix, only when `.git` belongs to the current user, to root, or to the user `sudo` ran for (`SUDO_UID`); a root process trusts every owner, since CI containers commonly run as root over a checkout owned by another uid (otherwise warning `socket_yml_repo_untrusted` and `--cwd` is the root). No `.git`: the root is `--cwd`. Only `/socket.yml` and `/socket.yaml` are read, matched by exact directory-entry name (`Socket.yml` is not read: warning `socket_yml_name_case`); nested files never are. A symlinked file is followed only to a regular file inside the repo root. `--global` / `--global-prefix` scans read no file. +**In memory (two-phase).** The host fetches the tree's root `socket.yml` / `socket.yaml` first and passes each to `selectHostedScanPaths` as `policyFiles: [{path, text} | {path, missing: true}]` (and `noSocketYml` when the session will bypass it). Selection applies the full path policy (defaults, `projectIgnorePaths`, `patches` lists, negations), so a negated default-ignored root is fetched and patched as on disk; an excluded root's markers come back in `presentOnly` so the session can report it. A listed policy file not passed, passed `missing`, symlinked or invalid makes selection return `policyError` with nothing selected. Selection returns `policyPaths` and `policySha256` (`null` without a file); the host streams the same text and passes both to the session, which fails `socket_yml_invalid` when the policy it reads differs from `policySha256`. + **Validation (fail closed).** Because the file only narrows, a file that cannot be honored never means "no policy". Checked in order: file access (a regular file after resolving, at most 64 KiB, read from the opened handle), encoding (UTF-8; a BOM is stripped and CRLF is fine; UTF-16 and NUL bytes are errors), YAML 1.2 syntax (duplicate keys, a non-mapping top level, nesting deeper than 32 and a second document are errors), a top-level key that looks like a misspelled `patches` (equal to `patch`/`patches` ignoring case, or within two edits of it and starting `pat`/`pac`, e.g. `patchs`), a top-level merge key (`<<`) or aliased key (either could carry a `patches` block other YAML readers apply), the version gate (`patches` requires `version: 2`), then the keys. Inside `patches` and `projectIgnorePaths`, anchors, aliases, merge keys (`<<`) and custom tags are errors (aliases elsewhere are never expanded). An unknown key under `patches` is an error with a did-you-mean hint and "a newer socket-patch may support it". Wrong types are errors — no coercion (`"false"` is not a bool; YAML 1.2, so `no` is a string) — as are an unknown severity, an out-of-range `maxNewPatches`, an invalid pattern or spec, a list over 1000 entries and an entry over 1024 bytes. Every error names the file and the key path (`patches.minSeverity`). `projectIgnorePaths` is validated strictly when a `patches` block exists (a single string is coerced to a one-element list); without one, a malformed value only warns `socket_yml_ignored_value` and is ignored, and it is honored whatever the `version`. An empty or comment-only file counts as no file. When both `socket.yml` and `socket.yaml` exist, both are validated; if their `projectIgnorePaths` and `patches` are equal as parsed values `socket.yml` is used, otherwise the run fails with `socket_yml_ambiguous`. **Error output.** Before any request or write, `scan` exits **1** with scan's error object plus an additive `errorCode` (`socket_yml_invalid` or `socket_yml_ambiguous`): `{"status": "error", "error": "socket.yml: patches.minSeverty: unknown key … (fix the file, or pass --no-socket-yml to ignore it)", "errorCode": "socket_yml_invalid", …}` with every count at zero; no `policy` block. Human output: `Error (socket_yml_invalid): …` on stderr. The in-memory engine reports `policyError: {code, detail}` (the detail without the CLI remedy) with no root processed and no file changed. diff --git a/crates/socket-patch-cli/src/commands/hosted_bundle.rs b/crates/socket-patch-cli/src/commands/hosted_bundle.rs index b1950f5c..277bc104 100644 --- a/crates/socket-patch-cli/src/commands/hosted_bundle.rs +++ b/crates/socket-patch-cli/src/commands/hosted_bundle.rs @@ -60,6 +60,8 @@ struct Bundle { min_severity: Option, #[serde(default)] policy_paths: Option>, + #[serde(default)] + policy_sha256: Option, } fn print_error(code: &str, message: &str) { @@ -131,6 +133,7 @@ pub async fn run(args: HostedBundleArgs) -> i32 { no_socket_yml: bundle.no_socket_yml, min_severity: bundle.min_severity.clone(), policy_paths: bundle.policy_paths.clone(), + policy_sha256: bundle.policy_sha256.clone(), ..HostedScanOptions::default() }; let input = match build_input(bundle, options) { diff --git a/crates/socket-patch-cli/src/hosted_memory/limits.rs b/crates/socket-patch-cli/src/hosted_memory/limits.rs index aebe45f4..43878724 100644 --- a/crates/socket-patch-cli/src/hosted_memory/limits.rs +++ b/crates/socket-patch-cli/src/hosted_memory/limits.rs @@ -29,6 +29,7 @@ pub(crate) struct ResolvedOptions { pub(crate) limits: ResolvedLimits, pub(crate) policy_overrides: socket_patch_core::policy::PolicyOverrides, pub(crate) policy_paths: Vec, + pub(crate) policy_sha256: Option, } pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result { @@ -129,6 +130,7 @@ pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result( paths: impl Iterator, roots: &[String], ecosystems: Option<&[String]>, + policy: &SelectionPolicy, out: &mut Vec, ) { let root_set: BTreeSet<&str> = roots.iter().map(String::as_str).collect(); @@ -336,7 +337,13 @@ fn unrooted_unsupported_warnings<'a>( || dir .split('/') .any(|seg| roots::EXCLUDED_ROOT_SEGMENTS.contains(&seg)) - || roots::default_ignored_dir(dir) + || policy + .admits_root(&Root { + rel_dir: dir, + markers: &[base.to_string()], + explicit: false, + }) + .is_err() { continue; } @@ -390,6 +397,22 @@ async fn engine( return Ok(policy_error_output(&error, warnings, files_input, bytes_input)); } }; + // Path selection chose which files to send by the policy it read; a + // different policy here would judge roots it never fetched. + let read = match policy.source() { + PolicySource::File { path, sha256 } => Some((path.as_str(), sha256.as_str())), + PolicySource::None | PolicySource::Bypassed => None, + }; + if !options.policy_overrides.bypass && read.map(|(_, sha)| sha) != options.policy_sha256.as_deref() { + let error = PolicyError::Invalid { + file: read.map_or(POLICY_FILE_NAMES[0], |(path, _)| path).to_string(), + key: String::new(), + message: "the policy content differs from the one path selection read: pass \ + selectHostedScanPaths' policySha256 and stream the same text" + .to_string(), + }; + return Ok(policy_error_output(&error, warnings, files_input, bytes_input)); + } for w in policy_warnings { warnings.push(EngineWarning::new(w.code, w.detail, None)); } @@ -404,7 +427,7 @@ async fn engine( let root_list: Vec = match &options.project_roots { Some(roots) => roots.clone(), - None => roots::detect_roots_with(files.keys().map(String::as_str), ecosystems, false).0, + None => roots::detect_roots(files.keys().map(String::as_str), ecosystems).0, }; // The full policy (paths from the file too) judges every root before // the project limit; roots named in `projectRoots` are explicit. @@ -447,6 +470,7 @@ async fn engine( files.keys().map(String::as_str), &detected_roots, ecosystems, + &policy, &mut warnings, ); let mut states: Vec = root_list @@ -1229,7 +1253,13 @@ mod tests { "src/Main.java", ]; let mut out = Vec::new(); - unrooted_unsupported_warnings(paths.into_iter(), &["web".to_string()], None, &mut out); + unrooted_unsupported_warnings( + paths.into_iter(), + &["web".to_string()], + None, + socket_patch_core::policy::builtin_defaults(), + &mut out, + ); assert_eq!(out.len(), 2); assert!(out .iter() @@ -1249,6 +1279,7 @@ mod tests { paths.into_iter(), &[], Some(&["npm".to_string()]), + socket_patch_core::policy::builtin_defaults(), &mut filtered, ); assert!(filtered.is_empty()); diff --git a/crates/socket-patch-cli/src/hosted_memory/roots.rs b/crates/socket-patch-cli/src/hosted_memory/roots.rs index 02873cf6..6bc569ac 100644 --- a/crates/socket-patch-cli/src/hosted_memory/roots.rs +++ b/crates/socket-patch-cli/src/hosted_memory/roots.rs @@ -54,22 +54,9 @@ pub(crate) const UNSUPPORTED_MARKERS: [(&str, &[&str]); 2] = [ /// Directory names whose subtrees never hold a project root: installed /// trees, VCS and tool state, and vendored dependencies. Structural, so no /// policy can negate them. (Test and fixture trees are the socket.yml -/// policy's overridable built-in ignores: [`default_ignored_dir`].) +/// policy's overridable built-in ignores.) pub(crate) const EXCLUDED_ROOT_SEGMENTS: [&str; 5] = ["node_modules", ".git", ".socket", ".yarn", "vendor"]; -/// Whether `dir` (repo-relative) is under a built-in default ignore of the -/// socket.yml policy (`test/`, `tests/`, `fixtures/`, …, any case). -pub(crate) fn default_ignored_dir(dir: &str) -> bool { - !dir.is_empty() - && socket_patch_core::policy::builtin_defaults() - .admits_root(&socket_patch_core::policy::Root { - rel_dir: dir, - markers: &[], - explicit: false, - }) - .is_err() -} - /// The marker basenames of `root` among `paths` (the files the policy's /// path filters test for that root). pub(crate) fn root_markers<'a>(root: &str, paths: impl IntoIterator) -> Vec { @@ -124,23 +111,12 @@ fn allowed(ecosystems: Option<&[String]>, eco: &str) -> bool { ecosystems.is_none_or(|list| list.iter().any(|e| e == eco)) } -/// The detected roots (sorted) and the marker paths that did not make one, -/// with the policy's built-in default ignores applied (path selection, -/// which cannot see socket.yml's content). +/// The detected roots (sorted) and the marker paths that did not make one. +/// The socket.yml path policy (built-in default ignores included) is the +/// caller's to apply. pub(crate) fn detect_roots<'a>( paths: impl IntoIterator, ecosystems: Option<&[String]>, -) -> (Vec, Vec) { - detect_roots_with(paths, ecosystems, true) -} - -/// [`detect_roots`]; `apply_defaults: false` leaves the built-in default -/// ignores to the caller (the session applies the full policy, whose -/// negations can re-include a default-ignored root). -pub(crate) fn detect_roots_with<'a>( - paths: impl IntoIterator, - ecosystems: Option<&[String]>, - apply_defaults: bool, ) -> (Vec, Vec) { let mut ignored: Vec = Vec::new(); let mut markers: BTreeMap> = BTreeMap::new(); @@ -185,20 +161,6 @@ pub(crate) fn detect_roots_with<'a>( .collect(); let mut roots: Vec = Vec::new(); for dir in markers.keys() { - let marker_names: Vec = marker_paths - .get(dir) - .into_iter() - .flatten() - .map(|p| split_path(p).1.to_string()) - .collect(); - let default_ignored = apply_defaults - && socket_patch_core::policy::builtin_defaults() - .admits_root(&socket_patch_core::policy::Root { - rel_dir: dir, - markers: &marker_names, - explicit: false, - }) - .is_err(); let rush_internal = rush_roots.iter().any(|r| { let internal = |sub: &str| join_root(r, sub); *dir == internal("common/config/rush") @@ -206,23 +168,15 @@ pub(crate) fn detect_roots_with<'a>( || *dir == internal("common/temp") || dir.starts_with(&format!("{}/", internal("common/temp"))) }); - let reason = if default_ignored { - Some("policy_path_excluded") - } else if rush_internal { - Some("rush_internal") - } else { - None - }; - match reason { - Some(reason) => { - for path in marker_paths.get(dir).into_iter().flatten() { - ignored.push(IgnoredPath { - path: path.clone(), - reason: reason.to_string(), - }); - } + if rush_internal { + for path in marker_paths.get(dir).into_iter().flatten() { + ignored.push(IgnoredPath { + path: path.clone(), + reason: "rush_internal".to_string(), + }); } - None => roots.push(dir.clone()), + } else { + roots.push(dir.clone()); } } roots.sort(); @@ -265,26 +219,14 @@ mod tests { ], None, ); - assert_eq!(found, vec!["docs"]); - assert_eq!(ignored.len(), 4); - let reason = |path: &str| ignored.iter().find(|i| i.path == path).unwrap().reason.clone(); - assert_eq!(reason("node_modules/x/package-lock.json"), "excluded_dir"); - assert_eq!(reason("a/vendor/b/composer.lock"), "excluded_dir"); - assert_eq!(reason(".socket/vendor/npm/package-lock.json"), "excluded_dir"); - // Test/fixture trees are the policy's overridable built-in ignores. - assert_eq!(reason("test/fixtures/yarn.lock"), "policy_path_excluded"); + // Test and fixture trees are left to the socket.yml path policy. + assert_eq!(found, vec!["docs", "test/fixtures"]); + assert_eq!(ignored.len(), 3); + assert!(ignored.iter().all(|i| i.reason == "excluded_dir")); } #[test] - fn default_ignores_are_case_insensitive_and_marker_based() { - let (found, _) = detect_roots( - ["Tests/app/yarn.lock", "e2e/testdata/go.mod", "apps/testing/package-lock.json"], - None, - ); - assert_eq!(found, vec!["apps/testing"]); - assert!(default_ignored_dir("a/__fixtures__")); - assert!(!default_ignored_dir("")); - assert!(!default_ignored_dir("apps/testing")); + fn root_markers_name_every_marker_of_the_root_only() { assert_eq!( root_markers("a", ["a/yarn.lock", "a/package.json", "a/b/yarn.lock", "a/pom.xml"]), vec!["pom.xml".to_string(), "yarn.lock".to_string()] diff --git a/crates/socket-patch-cli/src/hosted_memory/select.rs b/crates/socket-patch-cli/src/hosted_memory/select.rs index cae005f6..b7c8aa6e 100644 --- a/crates/socket-patch-cli/src/hosted_memory/select.rs +++ b/crates/socket-patch-cli/src/hosted_memory/select.rs @@ -14,10 +14,18 @@ use socket_patch_core::constants::npm_family::{ use socket_patch_core::patch::redirect::npmrc::NPMRC_REL; use socket_patch_core::utils::python_lock::is_python_lock_name; +use socket_patch_core::policy::{ + MemoryPolicyFs, PolicyOverrides, PolicySource, Root, RootFile, SelectionPolicy, POLICY_FILE_NAMES, + SOCKET_YML_INVALID, +}; + use super::roots::{ - detect_roots, split_path, strip_root, EXCLUDED_ROOT_SEGMENTS, UNSUPPORTED_MARKERS, + detect_roots, join_root, root_markers, split_path, strip_root, EXCLUDED_ROOT_SEGMENTS, + UNSUPPORTED_MARKERS, +}; +use super::types::{ + IgnoredPath, PathSelection, PolicyErrorInfo, PolicyFileInput, SelectOptions, TreeEntryInput, }; -use super::types::{IgnoredPath, PathSelection, SelectOptions, TreeEntryInput}; use crate::commands::scan::hosted::{PNPM_WORKSPACE_REL, REDIRECT_CANDIDATE_FILES}; /// Most entries [`PathSelection::ignored_sample`] carries. @@ -162,9 +170,60 @@ fn classify(rel: &str, root_files: &BTreeSet<&str>) -> Option { None } +/// The listed root policy files with the text the caller fetched first. A +/// listed file with no text (not passed, `missing`, or a symlink) is present +/// without content, so loading it fails closed. +fn selection_policy_fs(blobs: &BTreeMap, supplied: &[PolicyFileInput]) -> MemoryPolicyFs { + let mut fs = MemoryPolicyFs::default(); + for name in POLICY_FILE_NAMES { + let Some(&symlink) = blobs.get(name) else { + continue; + }; + let text = supplied + .iter() + .find(|f| f.path == name && !f.missing.unwrap_or(false)) + .and_then(|f| f.text.as_ref()); + let file = match text { + Some(text) if !symlink => RootFile::Present(text.as_bytes().to_vec()), + _ => RootFile::PresentWithoutContent, + }; + fs.files.insert(name.to_string(), file); + fs.root_names.push(name.to_string()); + } + fs +} + +/// The policy path selection applies, or why it cannot be honored. +fn selection_policy( + blobs: &BTreeMap, + options: &SelectOptions, +) -> Result { + let supplied = options.policy_files.as_deref().unwrap_or_default(); + if let Some(bad) = supplied.iter().find(|f| !POLICY_FILE_NAMES.contains(&f.path.as_str())) { + return Err(PolicyErrorInfo { + code: SOCKET_YML_INVALID.to_string(), + detail: format!( + "policyFiles entry `{}` is not a root socket.yml or socket.yaml", + socket_patch_core::policy::sanitize(&bad.path) + ), + }); + } + let overrides = PolicyOverrides { + bypass: options.no_socket_yml.unwrap_or(false), + min_severity: None, + }; + SelectionPolicy::load(&selection_policy_fs(blobs, supplied), &overrides) + .map(|(policy, _)| policy) + .map_err(|e| PolicyErrorInfo { + code: e.code().to_string(), + detail: e.detail(), + }) +} + /// `selectHostedScanPaths`: roots (detected, or `options.projectRoots`) -/// plus the files to stream for them. Only `blob` entries are files; mode -/// `120000` is a symbolic link and is reported, never fetched. +/// that the repo's socket.yml path policy admits, plus the files to stream +/// for them. Only `blob` entries are files; mode `120000` is a symbolic link +/// and is reported, never fetched. pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> PathSelection { let mut ignored: Vec = Vec::new(); let mut blobs: BTreeMap = BTreeMap::new(); @@ -183,7 +242,27 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path } } - let roots: Vec = match &options.project_roots { + let policy_paths: Vec = POLICY_FILE_NAMES + .iter() + .filter(|name| blobs.contains_key(**name)) + .map(|name| name.to_string()) + .collect(); + let policy = match selection_policy(&blobs, options) { + Ok(policy) => policy, + Err(error) => { + return PathSelection { + policy_paths, + policy_error: Some(error), + ..PathSelection::default() + } + } + }; + let policy_sha256 = match policy.source() { + PolicySource::File { sha256, .. } => Some(sha256.clone()), + PolicySource::None | PolicySource::Bypassed => None, + }; + + let candidate_roots: Vec = match &options.project_roots { Some(requested) => { let mut out: BTreeSet = BTreeSet::new(); for root in requested { @@ -208,6 +287,28 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path found } }; + // The same root filter the session applies, so a socket.yml negation + // of a built-in ignore brings that tree's files in here too. An + // excluded root's markers stay presence-only: the session sees the root + // and reports it filtered, as disk does, without its content. + let explicit = options.project_roots.is_some(); + let mut roots: Vec = Vec::with_capacity(candidate_roots.len()); + let mut excluded_markers: Vec = Vec::new(); + for root in candidate_roots { + let markers = root_markers(&root, blobs.keys().map(String::as_str)); + let admitted = policy + .admits_root(&Root { + rel_dir: &root, + markers: &markers, + explicit, + }) + .is_ok(); + if admitted { + roots.push(root); + } else { + excluded_markers.extend(markers.iter().map(|m| join_root(&root, m))); + } + } let root_set: BTreeSet<&str> = roots.iter().map(String::as_str).collect(); let mut per_root: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); @@ -235,7 +336,7 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path let Some(need) = classify(rel, files) else { continue; }; - let full = super::roots::join_root(root, rel); + let full = join_root(root, rel); let slot = needs.entry(full).or_insert(need); *slot = (*slot).min(need); } @@ -255,26 +356,31 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path && !dir .split('/') .any(|seg| EXCLUDED_ROOT_SEGMENTS.contains(&seg)) - && !super::roots::default_ignored_dir(dir) + && policy + .admits_root(&Root { + rel_dir: dir, + markers: &[base.to_string()], + explicit: false, + }) + .is_ok() }); if let Some(path) = first { needs.entry(path.clone()).or_insert(Need::Present); } } - // The repo-root policy files: always streamed when listed (a symlinked - // one lands in `symlinks`, and the session then fails closed on it). - let mut policy_paths: Vec = Vec::new(); - for name in socket_patch_core::policy::POLICY_FILE_NAMES { - if blobs.contains_key(name) { - needs.entry(name.to_string()).or_insert(Need::Text); - policy_paths.push(name.to_string()); - } + for path in excluded_markers { + needs.entry(path).or_insert(Need::Present); + } + // The session reads the same policy text again. + for name in &policy_paths { + needs.entry(name.clone()).or_insert(Need::Text); } let mut selection = PathSelection { roots, policy_paths, + policy_sha256, ..PathSelection::default() }; for (path, need) in needs { @@ -437,6 +543,7 @@ mod tests { &SelectOptions { project_roots: None, ecosystems: Some(vec!["npm".into()]), + ..SelectOptions::default() }, ); assert!(s.present_only.is_empty()); @@ -450,6 +557,7 @@ mod tests { &SelectOptions { project_roots: Some(vec!["b/".into(), "../x".into()]), ecosystems: None, + ..SelectOptions::default() }, ); assert_eq!(s.roots, vec!["b"]); diff --git a/crates/socket-patch-cli/src/hosted_memory/types.rs b/crates/socket-patch-cli/src/hosted_memory/types.rs index 04b07971..4e997a01 100644 --- a/crates/socket-patch-cli/src/hosted_memory/types.rs +++ b/crates/socket-patch-cli/src/hosted_memory/types.rs @@ -111,6 +111,10 @@ pub struct HostedScanOptions { /// content, or the session fails with `policyError`. #[serde(default, skip_serializing_if = "Option::is_none")] pub policy_paths: Option>, + /// The `policySha256` path selection returned: the session fails with + /// `policyError` when the policy it reads differs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_sha256: Option, } pub const DEFAULT_BATCH_SIZE: u32 = 100; @@ -328,6 +332,24 @@ pub struct SelectOptions { pub project_roots: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub ecosystems: Option>, + /// One entry per root socket.yml / socket.yaml the listing holds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_files: Option>, + /// Must match the session's `noSocketYml`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub no_socket_yml: Option, +} + +/// `SelectOptions.policyFiles` entry: the root policy file's text, or +/// `missing: true` when the host could not fetch it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyFileInput { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub missing: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -353,6 +375,13 @@ pub struct PathSelection { /// the session's `policyPaths`). #[serde(default)] pub policy_paths: Vec, + /// The policy file the selection applied (`null` without one): pass it + /// back as the session's `policySha256`. + #[serde(default)] + pub policy_sha256: Option, + /// A socket.yml that cannot be honored: nothing is selected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_error: Option, } /// Engine failure (`finish()` rejection codes). diff --git a/crates/socket-patch-cli/tests/hosted_memory_parity.rs b/crates/socket-patch-cli/tests/hosted_memory_parity.rs index b3027ffa..7c151db2 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -763,9 +763,70 @@ fn policy_repo(socket_yml: &str) -> (Vec, BTreeMap>) { (patches, repo) } -fn policy_options() -> socket_patch_cli::hosted_memory::HostedScanOptions { +/// The host's two-phase flow: fetch the root policy files, select with +/// their text, stream what selection asks for (presence-only paths marked +/// present) and pass selection's policy outputs to the session. +fn two_phase( + files: &BTreeMap>, + mut opts: socket_patch_cli::hosted_memory::HostedScanOptions, +) -> ( + socket_patch_cli::hosted_memory::PathSelection, + socket_patch_cli::hosted_memory::HostedScanInput, +) { + use socket_patch_cli::hosted_memory::{select_paths, PolicyFileInput, SelectOptions, TreeEntryInput}; + let entries: Vec = files + .iter() + .map(|(p, bytes)| TreeEntryInput { + path: p.clone(), + mode: "100644".into(), + kind: "blob".into(), + size: Some(bytes.len() as u64), + }) + .collect(); + let policy_files: Vec = ["socket.yml", "socket.yaml"] + .iter() + .filter_map(|name| { + files.get(*name).map(|bytes| PolicyFileInput { + path: name.to_string(), + text: Some(String::from_utf8(bytes.clone()).unwrap()), + missing: None, + }) + }) + .collect(); + let selection = select_paths( + &entries, + &SelectOptions { + policy_files: Some(policy_files), + no_socket_yml: opts.no_socket_yml, + ..SelectOptions::default() + }, + ); + let fetched: BTreeMap> = selection + .fetch_text + .iter() + .chain(selection.fetch_binary.iter()) + .map(|p| (p.clone(), files[p].clone())) + .collect(); + let present: Vec<&str> = selection.present_only.iter().map(String::as_str).collect(); + opts.policy_paths = Some(selection.policy_paths.clone()); + opts.policy_sha256 = selection.policy_sha256.clone(); + let input = build_input(&fetched, &present, opts); + (selection, input) +} + +fn policy_input(files: &BTreeMap>) -> socket_patch_cli::hosted_memory::HostedScanInput { + let (selection, input) = two_phase(files, options(false)); + assert!(selection.policy_error.is_none(), "{:?}", selection.policy_error); + input +} + +/// Session options as selection of `files` would hand them over, without +/// going through selection (for inputs a host may get wrong). +fn policy_options(files: &BTreeMap>) -> socket_patch_cli::hosted_memory::HostedScanOptions { + let (selection, _) = two_phase(files, options(false)); let mut opts = options(false); - opts.policy_paths = Some(vec!["socket.yml".to_string()]); + opts.policy_paths = Some(selection.policy_paths); + opts.policy_sha256 = selection.policy_sha256; opts } @@ -792,7 +853,7 @@ async fn parity_socket_yml_filters_the_same_roots_and_packages() { ); let server = MockServer::start().await; mount_api(&server, &patches).await; - let memory = run_engine(&server, build_input(&repo, &[], policy_options())).await; + let memory = run_engine(&server, policy_input(&repo)).await; assert!(memory.policy_error.is_none(), "{:?}", memory.policy_error); let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); assert_eq!(roots, vec!["apps/web", "services/api"], "the ignored root is not processed"); @@ -831,7 +892,7 @@ async fn parity_socket_yml_severity_floor() { let (patches, repo) = policy_repo("version: 2\npatches:\n minSeverity: critical\n"); let server = MockServer::start().await; mount_api(&server, &patches).await; - let memory = run_engine(&server, build_input(&repo, &[], policy_options())).await; + let memory = run_engine(&server, policy_input(&repo)).await; let web = memory.projects.iter().find(|p| p.root == "apps/web").unwrap(); assert!(web.redirected.is_empty(), "{:#}", web.redirect); assert!(web.skipped.iter().any(|s| s.reason == "policy_severity"), "{:?}", web.skipped); @@ -851,26 +912,44 @@ async fn memory_policy_file_withheld_or_invalid_is_a_policy_error() { let (patches, repo) = policy_repo("version: 2\npatches:\n maxNewPatches: 1\n"); let server = MockServer::start().await; mount_api(&server, &patches).await; + let opts = policy_options(&repo); // Listed by selection but never streamed. let mut withheld = repo.clone(); withheld.remove("socket.yml"); - let out = run_engine(&server, build_input(&withheld, &[], policy_options())).await; + let out = run_engine(&server, build_input(&withheld, &[], opts.clone())).await; let err = out.policy_error.expect("policyError"); assert_eq!(err.code, "socket_yml_invalid"); assert!(out.projects.is_empty() && out.changed_files.is_empty() && out.policy.is_none()); // Streamed present-without-content. - let out = run_engine(&server, build_input(&withheld, &["socket.yml"], policy_options())).await; + let out = run_engine(&server, build_input(&withheld, &["socket.yml"], opts.clone())).await; assert_eq!(out.policy_error.expect("policyError").code, "socket_yml_invalid"); - // Invalid content. + // Content other than what selection read. + let mut changed = repo.clone(); + changed.insert("socket.yml".to_string(), b"version: 2\n".to_vec()); + let out = run_engine(&server, build_input(&changed, &[], opts.clone())).await; + let err = out.policy_error.expect("policyError"); + assert!(err.detail.contains("differs"), "{}", err.detail); + // The file streamed without selection's policySha256. + let mut no_sha = opts.clone(); + no_sha.policy_sha256 = None; + let out = run_engine(&server, build_input(&repo, &[], no_sha)).await; + assert_eq!(out.policy_error.expect("policyError").code, "socket_yml_invalid"); + // Invalid content: selection refuses it before anything is fetched. let (_, bad) = policy_repo("version: 2\npatches:\n apiUrl: https://evil.example\n"); - let out = run_engine(&server, build_input(&bad, &[], policy_options())).await; + let (selection, _) = two_phase(&bad, options(false)); + let err = selection.policy_error.expect("selection policyError"); + assert!(err.detail.contains("patches.apiUrl"), "{}", err.detail); + assert!(selection.roots.is_empty() && selection.fetch_text.is_empty()); + let out = run_engine(&server, build_input(&bad, &[], opts.clone())).await; let err = out.policy_error.expect("policyError"); assert!(err.detail.contains("patches.apiUrl"), "{}", err.detail); assert!(out.changed_files.is_empty()); - // noSocketYml skips it. - let mut opts = policy_options(); - opts.no_socket_yml = Some(true); - let out = run_engine(&server, build_input(&bad, &[], opts)).await; + // noSocketYml skips it on both sides. + let mut bypass = options(false); + bypass.no_socket_yml = Some(true); + let (selection, input) = two_phase(&bad, bypass); + assert!(selection.policy_error.is_none() && selection.policy_sha256.is_none()); + let out = run_engine(&server, input).await; assert!(out.policy_error.is_none()); assert_eq!(out.policy.unwrap()["source"], "bypassed"); } @@ -880,56 +959,99 @@ async fn memory_min_severity_option_beats_the_file() { let (patches, repo) = policy_repo("version: 2\npatches:\n minSeverity: critical\n"); let server = MockServer::start().await; mount_api(&server, &patches).await; - let mut opts = policy_options(); + let mut opts = options(false); opts.min_severity = Some("none".to_string()); - let out = run_engine(&server, build_input(&repo, &[], opts)).await; + let (_, input) = two_phase(&repo, opts); + let out = run_engine(&server, input).await; let policy = out.policy.unwrap(); assert_eq!(policy["minSeverity"], serde_json::json!({"value": null, "source": "flag"})); assert!(out.projects.iter().any(|p| !p.redirected.is_empty())); - let mut bad = policy_options(); + let mut bad = options(false); bad.min_severity = Some("severe".to_string()); assert!(socket_patch_cli::hosted_memory::SessionBuilder::new(bad).is_err()); } #[test] -fn selection_streams_policy_files_and_applies_built_in_ignores() { - use socket_patch_cli::hosted_memory::{select_paths, SelectOptions, TreeEntryInput}; +fn selection_applies_the_path_policy_and_fails_closed() { + use socket_patch_cli::hosted_memory::{select_paths, PolicyFileInput, SelectOptions, TreeEntryInput}; let blob = |path: &str, mode: &str| TreeEntryInput { path: path.to_string(), mode: mode.to_string(), kind: "blob".into(), size: Some(1), }; - let entries = vec![ + let text = |path: &str, text: &str| PolicyFileInput { + path: path.to_string(), + text: Some(text.to_string()), + missing: None, + }; + let mut entries = vec![ blob("socket.yml", "100644"), - blob("socket.yaml", "120000"), blob("Socket.yml", "100644"), blob("apps/web/package-lock.json", "100644"), blob("apps/web/tests/app/package-lock.json", "100644"), blob("Fixtures/x/yarn.lock", "100644"), + blob("apps/old/yarn.lock", "100644"), ]; - let selection = select_paths(&entries, &SelectOptions::default()); - assert_eq!(selection.policy_paths, vec!["socket.yml", "socket.yaml"]); + let with = |files: Vec| SelectOptions { + policy_files: Some(files), + ..SelectOptions::default() + }; + let yml = "version: 2\npatches:\n ignorePaths: [\"/apps/old/\"]\n"; + let selection = select_paths(&entries, &with(vec![text("socket.yml", yml)])); + assert!(selection.policy_error.is_none(), "{:?}", selection.policy_error); + assert_eq!(selection.policy_paths, vec!["socket.yml"]); + assert_eq!(selection.policy_sha256.as_ref().map(String::len), Some(64)); assert!(selection.fetch_text.contains(&"socket.yml".to_string())); - assert!(selection.symlinks.contains(&"socket.yaml".to_string())); assert_eq!(selection.roots, vec!["apps/web"]); - assert!(selection - .ignored_sample - .iter() - .any(|i| i.path == "apps/web/tests/app/package-lock.json" && i.reason == "policy_path_excluded")); + // Excluded roots (file list and built-in ignores, any case) are + // presence-only: never fetched, still reported by the session. + for path in ["apps/old/yarn.lock", "apps/web/tests/app/package-lock.json", "Fixtures/x/yarn.lock"] { + assert!(selection.present_only.contains(&path.to_string()), "{path}: {selection:?}"); + assert!(!selection.fetch_text.contains(&path.to_string()), "{path}"); + } // Named roots are explicit: the built-in ignores do not apply. let named = select_paths( &entries, &SelectOptions { project_roots: Some(vec!["apps/web/tests/app".to_string()]), - ..SelectOptions::default() + ..with(vec![text("socket.yml", yml)]) }, ); assert_eq!(named.roots, vec!["apps/web/tests/app"]); + // A listed policy file with no text, `missing`, or invalid text fails + // closed: nothing is selected. + let missing = PolicyFileInput { + path: "socket.yml".to_string(), + text: None, + missing: Some(true), + }; + for files in [vec![], vec![missing], vec![text("socket.yml", "version: 2\npatches:\n apiUrl: x\n")]] { + let out = select_paths(&entries, &with(files)); + assert_eq!(out.policy_error.as_ref().map(|e| e.code.as_str()), Some("socket_yml_invalid")); + assert!(out.roots.is_empty() && out.fetch_text.is_empty(), "{out:?}"); + assert_eq!(out.policy_paths, vec!["socket.yml"]); + } + let out = select_paths(&entries, &with(vec![text("nested/socket.yml", yml)])); + assert!(out.policy_error.is_some()); + // A symlinked policy file is never read. + entries.push(blob("socket.yaml", "120000")); + let out = select_paths(&entries, &with(vec![text("socket.yml", yml), text("socket.yaml", yml)])); + assert_eq!(out.policy_error.map(|e| e.code), Some("socket_yml_invalid".to_string())); + // noSocketYml: only the built-in ignores; the file need not be passed. + let out = select_paths( + &entries, + &SelectOptions { + no_socket_yml: Some(true), + ..SelectOptions::default() + }, + ); + assert!(out.policy_error.is_none() && out.policy_sha256.is_none()); + assert_eq!(out.roots, vec!["apps/old", "apps/web"]); } #[tokio::test] -async fn memory_negation_reincludes_a_default_ignored_root_it_was_given() { +async fn memory_negation_reincludes_a_default_ignored_root() { let npm = fixtures_root().join("redirect/npm/package-lock-v3/basic"); let patches = patches_from_overrides(&npm.join("overrides.json"), None); let server = MockServer::start().await; @@ -944,11 +1066,23 @@ async fn memory_negation_reincludes_a_default_ignored_root_it_was_given() { "socket.yml".to_string(), b"version: 2\npatches:\n ignorePaths: [\"!/e2e/tests/\"]\n".to_vec(), ); - let memory = run_engine(&server, build_input(&repo, &[], policy_options())).await; + let (selection, input) = two_phase(&repo, options(false)); + assert_eq!(selection.roots, vec!["e2e/tests"]); + assert!(selection.fetch_text.contains(&"e2e/tests/package-lock.json".to_string())); + assert!(!selection.fetch_text.iter().any(|p| p.starts_with("x/")), "{selection:?}"); + let memory = run_engine(&server, input).await; let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); assert_eq!(roots, vec!["e2e/tests"]); + assert!(!memory.projects[0].redirected.is_empty(), "{:#}", memory.projects[0].redirect); let filtered = filtered_set(memory.policy.as_ref().unwrap()); assert!(filtered.contains(&("x/tests".to_string(), None, "policy_path_excluded".to_string()))); let entry = &memory.policy.as_ref().unwrap()["filtered"][0]; assert_eq!(entry["detail"], "tests/ (built-in default)"); + + // Disk patches the same root the same way. + let disk = run_disk_in(&server, &repo, "e2e/tests", false); + assert_eq!(disk.envelope["status"], "success", "{}", disk.stderr); + assert_eq!(memory.projects[0].redirect, disk.envelope["redirect"]); + let memory_changed = engine_changed(&memory); + assert_eq!(memory_changed, disk.changed, "{}", describe(&memory_changed)); } diff --git a/crates/socket-patch-node/npm/index.d.ts b/crates/socket-patch-node/npm/index.d.ts index e12e8d8c..4a1a1890 100644 --- a/crates/socket-patch-node/npm/index.d.ts +++ b/crates/socket-patch-node/npm/index.d.ts @@ -10,8 +10,12 @@ export interface PathSelection { ignoredCount: number ignoredSample: { path: string; reason: string }[] // ≤100 policyPaths: string[] // root socket.yml / socket.yaml the tree lists (also in fetchText or symlinks); pass back as the session's policyPaths + policySha256: string | null // the policy file selection applied; pass back as the session's policySha256 + policyError?: { code: 'socket_yml_invalid' | 'socket_yml_ambiguous'; detail: string } // nothing selected } -export function selectHostedScanPaths(entries: TreeEntryInput[], options?: { projectRoots?: string[]; ecosystems?: Ecosystem[] }): PathSelection +// Fetch every root socket.yml / socket.yaml the listing holds first and pass it in policyFiles: selection applies the full socket.yml path policy. +export type PolicyFileInput = { path: string; text: string } | { path: string; missing: true } +export function selectHostedScanPaths(entries: TreeEntryInput[], options?: { projectRoots?: string[]; ecosystems?: Ecosystem[]; policyFiles?: PolicyFileInput[]; noSocketYml?: boolean }): PathSelection export function hostedScanCandidateFiles(): string[] // debug listing only export function engineVersion(): string // "+" @@ -52,6 +56,7 @@ export interface HostedScanSessionOptions { noSocketYml?: boolean // ignore the repo's socket.yml (built-in test/fixture ignores still apply); default false minSeverity?: 'critical' | 'high' | 'medium' | 'moderate' | 'low' | 'none' // beats socket.yml patches.minSeverity policyPaths?: string[] // selectHostedScanPaths' policyPaths; each must be streamed with content or the session returns policyError + policySha256?: string // selectHostedScanPaths' policySha256; a policy file that differs (or arrives without it) is a policyError } export class HostedScanSession { constructor(options: HostedScanSessionOptions, provider: PatchProvider) diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index d5bc86a5..fff254a9 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -1057,19 +1057,21 @@ Gaps and contradictions A resolved with the smallest reasonable decision 8. **In-memory engine gaps until B lands its recorded view**: `retained[]` is empty and the floor rule of item 5 cannot see recorded pins (filtered packages' pins stay byte-identical regardless: the rewriters only touch - selected dependencies). `selectHostedScanPaths` applies the built-in - default ignores (the file's content is unknown at selection time), so a - socket.yml negation re-includes an in-memory root only when the host - streamed its files anyway; the session itself detects roots without the - defaults and applies the full policy. A root named in `projectRoots` is - explicit and skips the defaults. There - is no case-variant warning in memory (selection streams exact names - only). `ProjectResult.skipped[]` carries the post-lookup policy reasons + selected dependencies). A root named in `projectRoots` is explicit and + skips the defaults. There is no case-variant warning in memory + (selection reads exact names only). `ProjectResult.skipped[]` carries the post-lookup policy reasons (severity, disabled); the pre-lookup ones are in the session `policy` block only. 9. **Session option `policyPaths`** (selection's list, handed back like `projectRoots`) is how the session tells "listed but never sent" from - absent. + absent. Two-phase selection (7.2) names its outputs: selection returns + `policySha256` (`null` without a file) and the session takes it as an + option, since 7.2 does not say how the session learns what the + selector saw; a session that reads a policy file without it fails + closed. Selection also takes `noSocketYml` so both sides bypass + together. A root the selector excludes keeps its markers in + `presentOnly` (no content), so the session still lists it under + `policy.filtered[]` as disk does. 10. **Env layer of `--min-severity`** is read by scan, not clap, so the `policy` block can say `source: "env"`; a malformed env value exits 2 at run time, a malformed flag at parse time. From ed6f51c90ea6692fef4422368843e3d0942f86e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:55:35 +0000 Subject: [PATCH 12/14] Keep excluded roots out of the memory stream Review follow-ups to two-phase selection: - Roots the policy excludes are reported in ignoredSample instead of streamed presence-only, so a repo with many fixture lockfiles no longer runs into the session's file limit. - A session that bypasses socket.yml while selection applied it now fails closed instead of processing roots it never fetched. - The docs say policy text must decode losslessly (TextDecoder drops a BOM) and when policySha256 is null. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../socket-patch-cli/src/hosted_memory/mod.rs | 5 ++- .../src/hosted_memory/select.rs | 38 ++++++++-------- .../tests/hosted_memory_parity.rs | 43 +++++++++++++++---- crates/socket-patch-node/npm/index.d.ts | 3 +- docs/design/staged-rollout.md | 8 ++-- 6 files changed, 68 insertions(+), 33 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 8a24c086..3888cd86 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -29,7 +29,7 @@ Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex **Root `--update` flag.** `socket-patch --update [VERSION]` updates the binary itself from GitHub Releases. It is a root flag, not a subcommand: argv is rewritten (the same mechanism as the bare-UUID fallback) onto an internal hidden subcommand whose name carries no stability guarantee — script the flag, never the internal name. Combining the flag with a subcommand (`socket-patch --update scan`) is a usage error (exit 2). Full contract: [Self-update contract](#self-update-contract-socket-patch---update). -**Internal `hosted-bundle` subcommand.** `socket-patch hosted-bundle` is a hidden, INTERNAL parity/debug harness for the in-memory hosted engine (`src/hosted_memory/`, the engine the Node addon embeds): it reads a JSON bundle `{"files": {path: text}, "binaryFiles"?: {path: base64}, "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], "pipenvMajor"?: n, "batchSize"?: n}` on stdin, queries the authenticated org API built from `--api-url` / `--api-token` / `--org` only (both of the latter are required; no public-proxy fallback), and prints the engine result — or `{"status":"error","error":{"code","message"}}` with exit 1 (exit 2 for unusable input or missing credentials). It never touches the filesystem. Its name, input and output carry NO stability guarantee; do not script it. +**Internal `hosted-bundle` subcommand.** `socket-patch hosted-bundle` is a hidden, INTERNAL parity/debug harness for the in-memory hosted engine (`src/hosted_memory/`, the engine the Node addon embeds): it reads a JSON bundle `{"files": {path: text}, "binaryFiles"?: {path: base64}, "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], "pipenvMajor"?: n, "batchSize"?: n, "noSocketYml"?: bool, "minSeverity"?: s, "policyPaths"?: [path], "policySha256"?: s}` on stdin, queries the authenticated org API built from `--api-url` / `--api-token` / `--org` only (both of the latter are required; no public-proxy fallback), and prints the engine result — or `{"status":"error","error":{"code","message"}}` with exit 1 (exit 2 for unusable input or missing credentials). It never touches the filesystem. Its name, input and output carry NO stability guarantee; do not script it. ## Global arguments @@ -213,7 +213,7 @@ patches: **Lookup.** The repo root is the nearest ancestor of `--cwd` (inclusive) holding `.git` (a directory, a file for worktrees and submodules, or a symlink to either), not walking past a `GIT_CEILING_DIRECTORIES` entry or into the home directory (unless `--cwd` is it), and, on Unix, only when `.git` belongs to the current user, to root, or to the user `sudo` ran for (`SUDO_UID`); a root process trusts every owner, since CI containers commonly run as root over a checkout owned by another uid (otherwise warning `socket_yml_repo_untrusted` and `--cwd` is the root). No `.git`: the root is `--cwd`. Only `/socket.yml` and `/socket.yaml` are read, matched by exact directory-entry name (`Socket.yml` is not read: warning `socket_yml_name_case`); nested files never are. A symlinked file is followed only to a regular file inside the repo root. `--global` / `--global-prefix` scans read no file. -**In memory (two-phase).** The host fetches the tree's root `socket.yml` / `socket.yaml` first and passes each to `selectHostedScanPaths` as `policyFiles: [{path, text} | {path, missing: true}]` (and `noSocketYml` when the session will bypass it). Selection applies the full path policy (defaults, `projectIgnorePaths`, `patches` lists, negations), so a negated default-ignored root is fetched and patched as on disk; an excluded root's markers come back in `presentOnly` so the session can report it. A listed policy file not passed, passed `missing`, symlinked or invalid makes selection return `policyError` with nothing selected. Selection returns `policyPaths` and `policySha256` (`null` without a file); the host streams the same text and passes both to the session, which fails `socket_yml_invalid` when the policy it reads differs from `policySha256`. +**In memory (two-phase).** The host fetches the tree's root `socket.yml` / `socket.yaml` first and passes each to `selectHostedScanPaths` as `policyFiles: [{path, text} | {path, missing: true}]` (and `noSocketYml` when the session will bypass it). `text` must be a lossless UTF-8 decode (Node `buffer.toString('utf8')`; `TextDecoder` drops a BOM). Selection applies the full path policy (defaults, `projectIgnorePaths`, `patches` lists, negations), so a negated default-ignored root is fetched and patched as on disk; an excluded root is not streamed and is reported in `ignoredSample` with its `policy_*` reason (the session's `policy.filtered[]` lists only roots it received). Unless `noSocketYml`, a listed policy file not passed, passed `missing`, symlinked or invalid makes selection return `policyError` with nothing selected. Selection returns `policyPaths` and `policySha256` (`null` with no file, an empty file, or `noSocketYml`); the host streams the same text and passes both to the session, with the same `noSocketYml`. The session fails `socket_yml_invalid` when the policy it reads differs from `policySha256`, including a bypassed session given a digest. **Validation (fail closed).** Because the file only narrows, a file that cannot be honored never means "no policy". Checked in order: file access (a regular file after resolving, at most 64 KiB, read from the opened handle), encoding (UTF-8; a BOM is stripped and CRLF is fine; UTF-16 and NUL bytes are errors), YAML 1.2 syntax (duplicate keys, a non-mapping top level, nesting deeper than 32 and a second document are errors), a top-level key that looks like a misspelled `patches` (equal to `patch`/`patches` ignoring case, or within two edits of it and starting `pat`/`pac`, e.g. `patchs`), a top-level merge key (`<<`) or aliased key (either could carry a `patches` block other YAML readers apply), the version gate (`patches` requires `version: 2`), then the keys. Inside `patches` and `projectIgnorePaths`, anchors, aliases, merge keys (`<<`) and custom tags are errors (aliases elsewhere are never expanded). An unknown key under `patches` is an error with a did-you-mean hint and "a newer socket-patch may support it". Wrong types are errors — no coercion (`"false"` is not a bool; YAML 1.2, so `no` is a string) — as are an unknown severity, an out-of-range `maxNewPatches`, an invalid pattern or spec, a list over 1000 entries and an entry over 1024 bytes. Every error names the file and the key path (`patches.minSeverity`). `projectIgnorePaths` is validated strictly when a `patches` block exists (a single string is coerced to a one-element list); without one, a malformed value only warns `socket_yml_ignored_value` and is ignored, and it is honored whatever the `version`. An empty or comment-only file counts as no file. When both `socket.yml` and `socket.yaml` exist, both are validated; if their `projectIgnorePaths` and `patches` are equal as parsed values `socket.yml` is used, otherwise the run fails with `socket_yml_ambiguous`. diff --git a/crates/socket-patch-cli/src/hosted_memory/mod.rs b/crates/socket-patch-cli/src/hosted_memory/mod.rs index 849d94d4..0308757f 100644 --- a/crates/socket-patch-cli/src/hosted_memory/mod.rs +++ b/crates/socket-patch-cli/src/hosted_memory/mod.rs @@ -403,7 +403,10 @@ async fn engine( PolicySource::File { path, sha256 } => Some((path.as_str(), sha256.as_str())), PolicySource::None | PolicySource::Bypassed => None, }; - if !options.policy_overrides.bypass && read.map(|(_, sha)| sha) != options.policy_sha256.as_deref() { + // Selection returns no digest when it bypassed the file, so a digest + // with a bypassed session means the two sides disagree. + let expected = if options.policy_overrides.bypass { None } else { read.map(|(_, sha)| sha) }; + if expected != options.policy_sha256.as_deref() { let error = PolicyError::Invalid { file: read.map_or(POLICY_FILE_NAMES[0], |(path, _)| path).to_string(), key: String::new(), diff --git a/crates/socket-patch-cli/src/hosted_memory/select.rs b/crates/socket-patch-cli/src/hosted_memory/select.rs index b7c8aa6e..012c22cd 100644 --- a/crates/socket-patch-cli/src/hosted_memory/select.rs +++ b/crates/socket-patch-cli/src/hosted_memory/select.rs @@ -289,24 +289,31 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path }; // The same root filter the session applies, so a socket.yml negation // of a built-in ignore brings that tree's files in here too. An - // excluded root's markers stay presence-only: the session sees the root - // and reports it filtered, as disk does, without its content. + // excluded root is reported here, not streamed: its markers would + // count against the session's file limit. let explicit = options.project_roots.is_some(); let mut roots: Vec = Vec::with_capacity(candidate_roots.len()); - let mut excluded_markers: Vec = Vec::new(); for root in candidate_roots { let markers = root_markers(&root, blobs.keys().map(String::as_str)); - let admitted = policy - .admits_root(&Root { - rel_dir: &root, - markers: &markers, - explicit, - }) - .is_ok(); - if admitted { - roots.push(root); - } else { - excluded_markers.extend(markers.iter().map(|m| join_root(&root, m))); + match policy.admits_root(&Root { + rel_dir: &root, + markers: &markers, + explicit, + }) { + Ok(()) => roots.push(root), + Err(reason) => { + let paths: Vec = if markers.is_empty() { + vec![root.clone()] + } else { + markers.iter().map(|m| join_root(&root, m)).collect() + }; + for path in paths { + ignored.push(IgnoredPath { + path, + reason: reason.code().to_string(), + }); + } + } } } let root_set: BTreeSet<&str> = roots.iter().map(String::as_str).collect(); @@ -369,9 +376,6 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path } } - for path in excluded_markers { - needs.entry(path).or_insert(Need::Present); - } // The session reads the same policy text again. for name in &policy_paths { needs.entry(name.clone()).or_insert(Need::Text); diff --git a/crates/socket-patch-cli/tests/hosted_memory_parity.rs b/crates/socket-patch-cli/tests/hosted_memory_parity.rs index 7c151db2..b297eaf7 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -853,10 +853,18 @@ async fn parity_socket_yml_filters_the_same_roots_and_packages() { ); let server = MockServer::start().await; mount_api(&server, &patches).await; - let memory = run_engine(&server, policy_input(&repo)).await; + let (selection, input) = two_phase(&repo, options(false)); + assert!(selection.policy_error.is_none(), "{:?}", selection.policy_error); + let memory = run_engine(&server, input).await; assert!(memory.policy_error.is_none(), "{:?}", memory.policy_error); let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); assert_eq!(roots, vec!["apps/web", "services/api"], "the ignored root is not processed"); + // Selection reports the root it excluded; nothing of it is streamed. + assert!(selection + .ignored_sample + .iter() + .any(|i| i.path == "apps/legacy/package-lock.json" && i.reason == "policy_path_excluded")); + assert!(!selection.fetch_text.iter().chain(&selection.present_only).any(|p| p.starts_with("apps/legacy/"))); let memory_policy = memory.policy.clone().expect("policy block"); assert_eq!(memory_policy["source"], "file"); @@ -878,7 +886,9 @@ async fn parity_socket_yml_filters_the_same_roots_and_packages() { assert!(disk.changed.is_empty(), "{root}: an ignored root changes nothing"); } } - assert_eq!(filtered_set(&memory_policy), disk_filtered); + let mut memory_filtered = filtered_set(&memory_policy); + memory_filtered.insert(("apps/legacy".to_string(), None, "policy_path_excluded".to_string())); + assert_eq!(memory_filtered, disk_filtered); assert!(disk_filtered.contains(&("apps/legacy".to_string(), None, "policy_path_excluded".to_string()))); assert!(disk_filtered.contains(&( "services/api".to_string(), @@ -944,6 +954,11 @@ async fn memory_policy_file_withheld_or_invalid_is_a_policy_error() { let err = out.policy_error.expect("policyError"); assert!(err.detail.contains("patches.apiUrl"), "{}", err.detail); assert!(out.changed_files.is_empty()); + // A bypassed session with a selection that applied the file. + let mut half = opts.clone(); + half.no_socket_yml = Some(true); + let out = run_engine(&server, build_input(&repo, &[], half)).await; + assert_eq!(out.policy_error.expect("policyError").code, "socket_yml_invalid"); // noSocketYml skips it on both sides. let mut bypass = options(false); bypass.no_socket_yml = Some(true); @@ -1005,10 +1020,13 @@ fn selection_applies_the_path_policy_and_fails_closed() { assert!(selection.fetch_text.contains(&"socket.yml".to_string())); assert_eq!(selection.roots, vec!["apps/web"]); // Excluded roots (file list and built-in ignores, any case) are - // presence-only: never fetched, still reported by the session. + // reported and never streamed. for path in ["apps/old/yarn.lock", "apps/web/tests/app/package-lock.json", "Fixtures/x/yarn.lock"] { - assert!(selection.present_only.contains(&path.to_string()), "{path}: {selection:?}"); - assert!(!selection.fetch_text.contains(&path.to_string()), "{path}"); + assert!( + selection.ignored_sample.iter().any(|i| i.path == path && i.reason == "policy_path_excluded"), + "{path}: {selection:?}" + ); + assert!(!selection.fetch_text.contains(&path.to_string()) && !selection.present_only.contains(&path.to_string()), "{path}"); } // Named roots are explicit: the built-in ignores do not apply. let named = select_paths( @@ -1070,14 +1088,21 @@ async fn memory_negation_reincludes_a_default_ignored_root() { assert_eq!(selection.roots, vec!["e2e/tests"]); assert!(selection.fetch_text.contains(&"e2e/tests/package-lock.json".to_string())); assert!(!selection.fetch_text.iter().any(|p| p.starts_with("x/")), "{selection:?}"); + assert!(selection + .ignored_sample + .iter() + .any(|i| i.path == "x/tests/package-lock.json" && i.reason == "policy_path_excluded")); let memory = run_engine(&server, input).await; let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); assert_eq!(roots, vec!["e2e/tests"]); assert!(!memory.projects[0].redirected.is_empty(), "{:#}", memory.projects[0].redirect); - let filtered = filtered_set(memory.policy.as_ref().unwrap()); - assert!(filtered.contains(&("x/tests".to_string(), None, "policy_path_excluded".to_string()))); - let entry = &memory.policy.as_ref().unwrap()["filtered"][0]; - assert_eq!(entry["detail"], "tests/ (built-in default)"); + + // Given every root anyway, the session applies the same filter itself. + let direct = run_engine(&server, build_input(&repo, &[], policy_options(&repo))).await; + let roots: Vec<&str> = direct.projects.iter().map(|p| p.root.as_str()).collect(); + assert_eq!(roots, vec!["e2e/tests"]); + let entry = &direct.policy.as_ref().unwrap()["filtered"][0]; + assert_eq!((entry["project"].as_str(), entry["detail"].as_str()), (Some("x/tests"), Some("tests/ (built-in default)"))); // Disk patches the same root the same way. let disk = run_disk_in(&server, &repo, "e2e/tests", false); diff --git a/crates/socket-patch-node/npm/index.d.ts b/crates/socket-patch-node/npm/index.d.ts index 4a1a1890..c70f28fa 100644 --- a/crates/socket-patch-node/npm/index.d.ts +++ b/crates/socket-patch-node/npm/index.d.ts @@ -10,10 +10,11 @@ export interface PathSelection { ignoredCount: number ignoredSample: { path: string; reason: string }[] // ≤100 policyPaths: string[] // root socket.yml / socket.yaml the tree lists (also in fetchText or symlinks); pass back as the session's policyPaths - policySha256: string | null // the policy file selection applied; pass back as the session's policySha256 + policySha256: string | null // the policy file selection applied (null: none, empty, or noSocketYml); pass back as the session's policySha256 policyError?: { code: 'socket_yml_invalid' | 'socket_yml_ambiguous'; detail: string } // nothing selected } // Fetch every root socket.yml / socket.yaml the listing holds first and pass it in policyFiles: selection applies the full socket.yml path policy. +// text must decode losslessly (buffer.toString('utf8'); TextDecoder drops a BOM and the session then sees different content). Excluded roots are reported in ignoredSample, not streamed. export type PolicyFileInput = { path: string; text: string } | { path: string; missing: true } export function selectHostedScanPaths(entries: TreeEntryInput[], options?: { projectRoots?: string[]; ecosystems?: Ecosystem[]; policyFiles?: PolicyFileInput[]; noSocketYml?: boolean }): PathSelection export function hostedScanCandidateFiles(): string[] // debug listing only diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index fff254a9..5a9c32ca 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -1069,9 +1069,11 @@ Gaps and contradictions A resolved with the smallest reasonable decision option, since 7.2 does not say how the session learns what the selector saw; a session that reads a policy file without it fails closed. Selection also takes `noSocketYml` so both sides bypass - together. A root the selector excludes keeps its markers in - `presentOnly` (no content), so the session still lists it under - `policy.filtered[]` as disk does. + together; a bypassed session given a digest fails closed. A root the + selector excludes is reported in its `ignoredSample` with the + `policy_*` reason and never streamed (streaming its markers would count + every fixture lockfile against `maxFiles`), so in memory + `policy.filtered[]` lists only the roots the session received. 10. **Env layer of `--min-severity`** is read by scan, not clap, so the `policy` block can say `source: "env"`; a malformed env value exits 2 at run time, a malformed flag at parse time. From f680990366bafdb42f846eef0ad68d6a0f621333 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:36:35 +0000 Subject: [PATCH 13/14] Keep scan -h short with the policy flags The v5 help rules cap each command's short help at about eight options. `--no-socket-yml` and `--min-severity` pushed `scan -h` to ten, so they now appear only in `scan --help`, like the other advanced scan flags. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 --- crates/socket-patch-cli/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 7bc0e4d1..e0bd6136 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -177,6 +177,8 @@ fn short_help_hidden_own(sub: &str) -> &'static [&'static str] { "vex_no_verify", "vex_doc_id", "vex_compact", + "no_socket_yml", + "min_severity", ], "get" => &["id", "cve", "ghsa", "package", "save_only", "one_off", "all_releases"], "vex" => &["doc_id", "compact"], From 65ef71fd4f454aacc0c1f0aed2c3117e79ed624c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:49:30 +0000 Subject: [PATCH 14/14] Fail report-only JSON when detail queries fail A report-only `scan --json` (`--prune` with no mode) with a severity floor or `enabled: false` fetches patch details to fill `policy.filtered[]`. If every query failed it still printed a success envelope and exited 0. It now reports the error and exits 1, like the agent and vendored runs. Human-mode policy warnings now print `Warning: ` like the rest of `scan`; the code stays in the JSON `warnings[]` entry. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 --- .../socket-patch-cli/src/commands/scan/mod.rs | 8 +++++-- .../src/commands/scan/policy.rs | 2 +- .../tests/e2e_socket_yml_policy.rs | 24 +++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 14b5d367..9d1fad1c 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -2057,7 +2057,7 @@ async fn run_scan( // `enabled: false` still hides candidates; report them like the // human arm does (the detail fetch runs only then). if !apply && !vendor && policy.reports_selection() && !all_packages_with_patches.is_empty() { - let _ = discover_selected( + if let Err((code, message)) = discover_selected( &api_client, &all_packages_with_patches, can_access_paid_patches, @@ -2067,7 +2067,11 @@ async fn run_scan( telemetry, Some(&mut result), ) - .await; + .await + { + emit_discovery_error_json(&mut result, &message); + return code; + } } // --- Apply path (if requested) ----------------------------------- diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 79385fb5..e319661e 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -480,7 +480,7 @@ impl ScanPolicy { return; } for w in &self.warnings { - eprintln!("Warning ({}): {}", w.code, w.detail); + eprintln!("Warning: {}", w.detail); } } diff --git a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs index 12e9fd1b..cc354014 100644 --- a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -684,6 +684,30 @@ async fn enabled_false_reports_and_writes_nothing() { assert_eq!(doc["redirect"]["redirected"], 0); } +#[tokio::test] +#[serial] +async fn report_only_json_fails_when_every_detail_query_fails() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .respond_with(ResponseTemplate::new(500)) + .with_priority(1) + .mount(&server) + .await; + mount_api(&server, catalog()).await; + // `--prune` with no mode is the report-only arm. + let repo = Repo::new(Some("version: 2\npatches:\n minSeverity: critical\n")); + let before = repo.snapshot(); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--prune"], &[]); + assert_eq!(code, 1, "{doc:#}"); + assert_eq!(doc["status"], "error", "{doc:#}"); + assert!( + doc["error"].as_str().unwrap_or_default().contains("patch-detail queries failed"), + "{doc:#}" + ); + assert_eq!(repo.snapshot(), before); +} + #[tokio::test] #[serial] async fn recorded_merged_patch_below_a_new_floor_is_kept() {