diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c45ee7c..1c261cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -652,8 +652,57 @@ 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, 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` + 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`). 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 project and every critical/high patch the severity + 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 `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/Cargo.lock b/Cargo.lock index 4a1aa3b4..efa5fd8b 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" @@ -1799,6 +1920,7 @@ dependencies = [ "fs2", "futures-util", "hex", + "ignore", "libc", "once_cell", "qbsdiff", @@ -1809,6 +1931,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/README.md b/README.md index 6045f81d..6cb2ba7c 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 @@ -647,6 +651,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-archive files (plus any legacy package archives). [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. | @@ -692,6 +698,72 @@ 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. + (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`). +- 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 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`, +`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 c9af4822..6d2e797d 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -30,7 +30,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 (`socket-patch-core` `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 (`socket-patch-core` `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 @@ -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` | — | 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. The former opt-in for exactly that, `--detached`, is removed in v5.0 (unknown-flag usage error) | | `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`); `--mode ` | `SOCKET_SAVE_ONLY` | 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 | @@ -175,6 +177,87 @@ 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 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. 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). `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`. + +**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. + +**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 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`) `--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. @@ -929,6 +1012,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. | | `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_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. | | `SOCKET_SKIP_ROLLBACK` | `remove --skip-rollback` | `false` | Local to `remove`. Conflicts with `--preserve-state`/`SOCKET_PRESERVE_STATE` (exit 2 — see below). | @@ -972,7 +1057,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 @@ -1471,8 +1556,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, an unknown subcommand such as the removed `setup`) 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`) 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). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--mode hosted\|vendored --save-only`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`) — previously `1` (MAJOR). The never-implemented `get --one-off` / `rollback --one-off` (and `SOCKET_ONE_OFF`) are removed in v5.0; `--one-off` is now an ordinary unknown-flag clap error. | +| `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, an unknown subcommand such as the removed `setup`) 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`) 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). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--mode hosted\|vendored --save-only`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`) — previously `1` (MAJOR). The never-implemented `get --one-off` / `rollback --one-off` (and `SOCKET_ONE_OFF`) are removed in v5.0; `--one-off` is now an ordinary unknown-flag clap error. | `list` returns **`0`** for every project it can read, empty or not (**v5.0, BREAKING**: a project with no manifest and no ledger record — normal for hosted mode, which writes no manifest — used to exit `1` with `manifest_not_found`; it is now an empty list: `No patches in this project. Run \`socket-patch scan\`.` on stdout, and under `--json` the success envelope with `events: []`). Only an unreadable or invalid manifest (`manifest_unreadable` / `manifest_invalid`) exits `1`. 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/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 16a34a65..e02f3908 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -2935,7 +2935,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 { @@ -2956,6 +2956,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..277bc104 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,14 @@ 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>, + #[serde(default)] + policy_sha256: Option, } fn print_error(code: &str, message: &str) { @@ -121,6 +130,10 @@ 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(), + policy_sha256: bundle.policy_sha256.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 6c999e80..078df2b8 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -520,11 +520,13 @@ 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, 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 @@ -539,11 +541,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, @@ -551,7 +553,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 38f8fdc6..39dece92 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -32,11 +32,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; @@ -332,46 +337,13 @@ pub struct ScanArgs { /// VEX makes the command exit non-zero. #[command(flatten)] 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()) + #[command(flatten)] + pub socket_yml: SocketYmlArgs, } +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 /// document from the post-scan manifest and fold the outcome into @@ -500,9 +472,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 @@ -514,12 +486,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 @@ -539,6 +511,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( @@ -547,26 +520,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) +} + +/// 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) } /// `common` for `select_patches`: scan never prompts, so it always takes @@ -1202,41 +1173,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) => { @@ -1244,6 +1228,21 @@ 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 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() + ); + return 2; + } + } + } if args.common.json && dirs.len() > 1 { eprintln!( "Error: --json takes one project directory ({} given); run one scan per directory", @@ -1252,7 +1251,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()); @@ -1260,12 +1259,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 @@ -1277,14 +1293,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) { @@ -1315,7 +1356,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[]`. @@ -1436,6 +1478,33 @@ 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 = ctx.ledgers().await.manifest; + // 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(ctx.discovery().await) + }; + let hosted_state = (!args.common.is_global()) + .then(|| crate::commands::hosted_state_from_pins(&hosted_pin_list)); + let redirect_state = hosted_state.as_ref(); + 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, + 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() @@ -1498,6 +1567,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(); @@ -1507,6 +1583,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { for (_, detail) in &layout_refusals { eprintln!("Warning: {detail}"); } + policy.print_warnings(args.common.silent); // Hosted mode already printed its own prune-ignored warning. if prune && !hosted { eprintln!("{}", render::PRUNE_SKIPPED_EMPTY); @@ -1553,6 +1630,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 { @@ -1587,14 +1665,18 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { 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; } @@ -1632,6 +1714,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { for (_, detail) in &layout_refusals { eprintln!("Warning: {detail}"); } + policy.print_warnings(args.common.silent); } // Query API in batches @@ -1833,31 +1916,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 = ctx.ledgers().await.manifest; - // 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(ctx.discovery().await) - }; - let hosted_state = (!args.common.is_global()) - .then(|| crate::commands::hosted_state_from_pins(&hosted_pin_list)); - let redirect_state = hosted_state.as_ref(); - 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, - 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` @@ -1892,6 +1952,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. @@ -1916,6 +1977,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(), @@ -1942,13 +2004,34 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { 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() { + if let Err((code, message)) = discover_selected( + &api_client, + &all_packages_with_patches, + can_access_paid_patches, + &policy, + false, + false, + telemetry, + Some(&mut result), + ) + .await + { + emit_discovery_error_json(&mut result, &message); + return code; + } + } + // --- 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, @@ -1956,7 +2039,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; @@ -2068,6 +2151,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, @@ -2124,7 +2208,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, @@ -2292,11 +2378,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, @@ -2304,12 +2390,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())) @@ -2341,14 +2428,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. @@ -2610,14 +2696,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")); @@ -2986,20 +3085,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..0cd466bf --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -0,0 +1,550 @@ +//! 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::{ + canon, find_repo_root_with_warnings, policy_block, FilteredEntry, RetainedEntry, 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; + +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 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, + /// 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 +/// 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(), + warned: Default::default(), + }); + } + 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, + warned: Default::default(), + }) +} + +/// 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(); + 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", +]; + +#[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, + /// 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). + 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(), + }); + } + 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), + } + } + + /// 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()) + } + + /// 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, + project: self.project.clone(), + recorded_uuid: uuid.to_string(), + reason, + upgrade_available: false, + }); + } + return true; + } + if self.root_verdict.is_err() { + // Already reported as the root's one entry. + } else if report.filtered_purls.insert(canon(purl)) { + report.filtered.push(FilteredEntry { + purl: Some(canon(purl)), + uuid: None, + project: self.project.clone(), + 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, + project: self.project.clone(), + recorded_uuid: uuid, + reason, + upgrade_available: false, + }); + } + } + None => report.filtered.push(FilteredEntry { + purl: Some(canon(&purl)), + uuid: Some(group[0].uuid.clone()), + project: self.project.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), + (_, 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(canon(&purl)), + uuid: Some(group[0].uuid.clone()), + project: self.project.clone(), + severity: Some(patch_severity_order(&group[0])), + reason, + }); + } + } + 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 retained: Vec = report + .retained + .iter() + .map(|r| RetainedEntry { + upgrade_available: report.update_purls.contains(&r.purl), + ..r.clone() + }) + .collect(); + policy_block(&self.policy, &report.filtered, &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 || !self.announce_warnings { + return; + } + for w in &self.warnings { + eprintln!("Warning: {}", 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 && self.policy.enabled() { + 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}"); + 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 || f.purl.is_none()) { + continue; + } + let what = match &f.purl { + Some(purl) => sanitize(&normalize_purl(purl)), + 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 {}: {}", + sanitize(&normalize_purl(&r.purl)), + sanitize(&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, + }) +} + +/// `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/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 eb38a147..9b2e11e8 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -452,6 +452,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, @@ -469,11 +470,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, @@ -481,7 +482,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; @@ -780,6 +781,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, @@ -797,6 +799,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/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 371a8c88..297fb9f3 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -180,6 +180,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", "all_releases"], "vex" => &["doc_id", "compact"], diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 64bb72a1..d6af0472 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", @@ -472,6 +474,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. @@ -526,6 +530,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, @@ -854,3 +870,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 new file mode 100644 index 00000000..cc354014 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -0,0 +1,990 @@ +//! 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 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() { + 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"); + 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] +#[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("is outside") && stderr.contains("run one scan per repository"), "{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"); +} + +// --------------------------------------------------------------------------- +// 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}"); +} + +#[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 9ff12449..6d8b2427 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs @@ -305,13 +305,28 @@ 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(); } + // 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(); for keep in [ @@ -349,7 +364,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", @@ -365,7 +380,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..b297eaf7 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -745,3 +745,369 @@ 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) +} + +/// 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(selection.policy_paths); + opts.policy_sha256 = selection.policy_sha256; + 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 (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"); + + 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"); + } + } + 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(), + 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, 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); + 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; + 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, &[], 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"], opts.clone())).await; + assert_eq!(out.policy_error.expect("policyError").code, "socket_yml_invalid"); + // 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 (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()); + // 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); + 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"); +} + +#[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 = options(false); + opts.min_severity = Some("none".to_string()); + 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 = options(false); + bad.min_severity = Some("severe".to_string()); + assert!(socket_patch_cli::hosted_memory::SessionBuilder::new(bad).is_err()); +} + +#[test] +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 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.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 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_eq!(selection.roots, vec!["apps/web"]); + // Excluded roots (file list and built-in ignores, any case) are + // reported and never streamed. + for path in ["apps/old/yarn.lock", "apps/web/tests/app/package-lock.json", "Fixtures/x/yarn.lock"] { + 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( + &entries, + &SelectOptions { + project_roots: Some(vec!["apps/web/tests/app".to_string()]), + ..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() { + 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 (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:?}"); + 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); + + // 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); + 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-cli/tests/in_process_cargo_apply.rs b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs index 9502d312..8c7dc960 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 { @@ -335,6 +336,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 { @@ -436,6 +438,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 af56d13a..45cbaedd 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 { @@ -310,6 +311,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 fba7f6c2..1b0f8e35 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 fa520987..e169dffe 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 { @@ -323,6 +324,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 { @@ -431,6 +433,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 { @@ -559,6 +562,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 3e4b098e..28fa350a 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 5d759c5c..855b04fd 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 5eb700f1..25273828 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 ea99779f..a72ca5c3 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -151,6 +151,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 2c80118b..738d2c91 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 ddc76a93..4ad77067 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -77,6 +77,7 @@ async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { /// `--mode hosted`. 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 5b1bb834..d90c2039 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs @@ -120,6 +120,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 1ff92dcc..7a50373a 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 19ff8dae..d79e89f8 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 e9d816d8..5521dded 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 122d3d03..20bcfa5f 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 { diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml index 7bbbb82e..5b78b852 100644 --- a/crates/socket-patch-core/Cargo.toml +++ b/crates/socket-patch-core/Cargo.toml @@ -44,6 +44,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/hosted/memory/limits.rs b/crates/socket-patch-core/src/hosted/memory/limits.rs index f84dbbed..af11729c 100644 --- a/crates/socket-patch-core/src/hosted/memory/limits.rs +++ b/crates/socket-patch-core/src/hosted/memory/limits.rs @@ -27,6 +27,9 @@ pub(crate) struct ResolvedOptions { pub(crate) provider_concurrency: usize, pub(crate) request_timeout: std::time::Duration, pub(crate) limits: ResolvedLimits, + pub(crate) policy_overrides: crate::policy::PolicyOverrides, + pub(crate) policy_paths: Vec, + pub(crate) policy_sha256: Option, } pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result { @@ -54,6 +57,28 @@ pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result None, + Some(value) => Some(( + crate::policy::parse_min_severity(value) + .map_err(|e| EngineError::invalid("invalid_min_severity", format!("minSeverity: {e}")))?, + crate::policy::OverrideSource::Flag, + )), + }; + let policy_overrides = crate::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 !crate::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 +128,9 @@ pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result+"`; the sha comes from the /// `SOCKET_PATCH_GIT_SHA` build-time variable. @@ -113,6 +117,8 @@ struct RootState { packages: Vec, selected: Vec<(String, String)>, skipped: Vec, + /// Candidates the socket.yml policy withheld (`policy_*` reasons). + policy_skipped: Vec, error: Option, } @@ -311,6 +317,7 @@ fn unrooted_unsupported_warnings<'a>( 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(); @@ -328,6 +335,13 @@ fn unrooted_unsupported_warnings<'a>( || dir .split('/') .any(|seg| roots::EXCLUDED_ROOT_SEGMENTS.contains(&seg)) + || policy + .admits_root(&Root { + rel_dir: dir, + markers: &[base.to_string()], + explicit: false, + }) + .is_err() { continue; } @@ -372,10 +386,77 @@ 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)); + } + }; + // 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, + }; + // 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(), + 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)); + } + 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,8 +469,9 @@ async fn engine( } unrooted_unsupported_warnings( files.keys().map(String::as_str), - &root_list, + &detected_roots, ecosystems, + &policy, &mut warnings, ); let mut states: Vec = root_list @@ -407,6 +489,7 @@ async fn engine( packages: Vec::new(), selected: Vec::new(), skipped: Vec::new(), + policy_skipped: Vec::new(), error: None, }) .collect(); @@ -432,7 +515,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(canon(&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 @@ -536,7 +632,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"); @@ -629,7 +732,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, @@ -638,6 +741,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()); @@ -656,12 +760,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(), }); } @@ -698,9 +804,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: &crate::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.detail(), + }), + } +} + +/// 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| crate::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(canon(&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, @@ -854,6 +1064,7 @@ mod tests { packages: Vec::new(), selected: Vec::new(), skipped: Vec::new(), + policy_skipped: Vec::new(), error: None, } } @@ -1050,7 +1261,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, + crate::policy::builtin_defaults(), + &mut out, + ); assert_eq!(out.len(), 2); assert!(out .iter() @@ -1070,6 +1287,7 @@ mod tests { paths.into_iter(), &[], Some(&["npm".to_string()]), + crate::policy::builtin_defaults(), &mut filtered, ); assert!(filtered.is_empty()); diff --git a/crates/socket-patch-core/src/hosted/memory/roots.rs b/crates/socket-patch-core/src/hosted/memory/roots.rs index c715f85a..d22d2493 100644 --- a/crates/socket-patch-core/src/hosted/memory/roots.rs +++ b/crates/socket-patch-core/src/hosted/memory/roots.rs @@ -14,7 +14,7 @@ use super::types::IgnoredPath; /// Marker files of the ecosystems the in-memory engine cannot inventory /// (disk discovers them only through installed-tree crawlers). -pub(crate) const UNSUPPORTED_MARKERS: [(&str, &[&str]); 2] = [ +pub const UNSUPPORTED_MARKERS: [(&str, &[&str]); 2] = [ ( "maven", &[ @@ -29,23 +29,31 @@ 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.) +pub(crate) const EXCLUDED_ROOT_SEGMENTS: [&str; 5] = ["node_modules", ".git", ".socket", ".yarn", "vendor"]; + +/// 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: a [`registry::ROOT`] /// row (manifests alone never make a root) or a PEP 751 / PEP 723 lock. -pub(crate) fn marker_ecosystem(base: &str) -> Option<&'static str> { +pub fn marker_ecosystem(base: &str) -> Option<&'static str> { if let Some(row) = registry::root_marker(base) { return Some(row.ecosystem); } @@ -82,6 +90,8 @@ fn allowed(ecosystems: Option<&[String]>, eco: &str) -> bool { } /// 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]>, @@ -133,21 +143,15 @@ pub(crate) fn detect_roots<'a>( || *dir == internal("common/temp") || dir.starts_with(&format!("{}/", internal("common/temp"))) }); - let reason = 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(); @@ -190,11 +194,20 @@ mod tests { ], None, ); - assert_eq!(found, vec!["docs"]); - assert_eq!(ignored.len(), 4); + // 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 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()] + ); + } + #[test] fn rush_internals_are_not_roots_but_nested_cargo_locks_are() { assert_eq!( diff --git a/crates/socket-patch-core/src/hosted/memory/select.rs b/crates/socket-patch-core/src/hosted/memory/select.rs index 76750320..be43ec8e 100644 --- a/crates/socket-patch-core/src/hosted/memory/select.rs +++ b/crates/socket-patch-core/src/hosted/memory/select.rs @@ -14,10 +14,18 @@ use crate::constants::npm_family::{ use crate::patch::redirect::npmrc::NPMRC_REL; use crate::utils::python_lock::is_python_lock_name; +use crate::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::hosted::engine::{REDIRECT_CANDIDATE_FILES, RUSH_REPO_STATE_REL}; use crate::hosted::guidance::PNPM_WORKSPACE_REL; @@ -160,9 +168,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", + crate::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(); @@ -181,7 +240,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 { @@ -206,6 +285,35 @@ 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 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()); + for root in candidate_roots { + let markers = root_markers(&root, blobs.keys().map(String::as_str)); + 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(); let mut per_root: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); @@ -233,7 +341,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); } @@ -253,14 +361,28 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path && !dir .split('/') .any(|seg| EXCLUDED_ROOT_SEGMENTS.contains(&seg)) + && 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 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 { @@ -423,6 +545,7 @@ mod tests { &SelectOptions { project_roots: None, ecosystems: Some(vec!["npm".into()]), + ..SelectOptions::default() }, ); assert!(s.present_only.is_empty()); @@ -436,6 +559,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-core/src/hosted/memory/types.rs b/crates/socket-patch-core/src/hosted/memory/types.rs index f29c5777..e982c14e 100644 --- a/crates/socket-patch-core/src/hosted/memory/types.rs +++ b/crates/socket-patch-core/src/hosted/memory/types.rs @@ -100,6 +100,21 @@ 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>, + /// 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; @@ -279,6 +294,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`. @@ -301,6 +333,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)] @@ -322,6 +372,17 @@ 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, + /// 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-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index a55bb77d..c16adb55 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod hosted; pub mod ledgers; pub mod manifest; pub mod patch; +pub mod policy; pub mod telemetry; pub mod update; pub mod utils; 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..3e922569 --- /dev/null +++ b/crates/socket-patch-core/src/policy/mod.rs @@ -0,0 +1,892 @@ +//! 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; +mod report; +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::report::{canon, policy_block, FilteredEntry, RetainedEntry}; +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; + +/// 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| !unsafe_for_output(*c)) + .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}: {}", 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] + ), + } + } +} + +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. O_NOFOLLOW: the path was resolved already. + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW) + .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); + } + // 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(&target)?; + 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() +} + +/// 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 +/// 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 { + 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, + ) -> Result { + let defaults = defaults_list(); + 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, + 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::unrestricted(); + policy.source = PolicySource::Bypassed; + 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(name, 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; + 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() }, 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, sudo_uid: Option) -> bool { + euid == 0 || owner == euid || owner == 0 || sudo_uid == Some(owner) +} + +#[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 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(); + let home = home_dir(); + let mut warnings = Vec::new(); + let mut dir: &Path = &cwd; + loop { + if dir != cwd && home.as_deref() == Some(dir) { + break; + } + // `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); + } + 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/report.rs b/crates/socket-patch-core/src/policy/report.rs new file mode 100644 index 00000000..3279b195 --- /dev/null +++ b/crates/socket-patch-core/src/policy/report.rs @@ -0,0 +1,88 @@ +//! The `policy` JSON block (4.7) both engines report: disk scans and the +//! in-memory engine build the same entries and render them here. + +use super::{severity_name, FilterReason, PolicySource, SelectionPolicy}; +use crate::utils::purl::{normalize_purl, strip_purl_qualifiers}; + +/// The canonical spelling filters and the recorded view compare on. +pub fn canon(purl: &str) -> String { + normalize_purl(strip_purl_qualifiers(purl)).into_owned() +} + +/// One `policy.filtered[]` entry. +#[derive(Debug, Clone)] +pub 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)] +pub 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 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(); + // 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| { + 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, + }) +} 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..30a99499 --- /dev/null +++ b/crates/socket-patch-core/src/policy/socket_yml.rs @@ -0,0 +1,1277 @@ +//! 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: sanitize(&key.into()), + message: super::strip_unsafe(&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 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('@') + }); + 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 { + // 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(); + 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`", + )); + } + 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: super::strip_unsafe(&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, 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"), + ] { + 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 [ + "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", + ] { + 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}" + ); + } + } + + #[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..fd824749 --- /dev/null +++ b/crates/socket-patch-core/src/policy/tests.rs @@ -0,0 +1,594 @@ +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, 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 symlinked_git_marks_the_repo_root() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + 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); + } +} + +#[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-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/crates/socket-patch-node/npm/index.d.ts b/crates/socket-patch-node/npm/index.d.ts index e5fc61dc..c70f28fa 100644 --- a/crates/socket-patch-node/npm/index.d.ts +++ b/crates/socket-patch-node/npm/index.d.ts @@ -9,8 +9,14 @@ 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 + 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 } -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. +// 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 export function engineVersion(): string // "+" @@ -48,6 +54,10 @@ 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 + policySha256?: string // selectHostedScanPaths' policySha256; a policy file that differs (or arrives without it) is a policyError } export class HostedScanSession { constructor(options: HostedScanSessionOptions, provider: PatchProvider) @@ -63,7 +73,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 +84,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 { 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 38f916e8..5a9c32ca 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -1019,6 +1019,85 @@ 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). 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. 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 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. +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. +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) - A separate upgrade cap (`maxUpgrades`) if server-side republishes rotate 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}`)