Skip to content

Commit eccfe2c

Browse files
committed
Fix Poetry CI line endings and stale hosted attestations
Preserve LF/CRLF in the relock simulator and exercise both newline forms. Probe installed Python package hashes after hosted redirects, warn on stale bytes, and exclude them from same-run VEX even when another interpreter is patched. Cover re-scans, ledger fallback, missing files, dry runs, variants, and custom prefixes. Assisted-by: Codex:gpt-6-astra
1 parent 806fe3f commit eccfe2c

7 files changed

Lines changed: 494 additions & 42 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ into the new version's section — see docs/releasing.md.
200200

201201
### Fixed
202202

203+
- Hosted Python redirects now warn when installed files still contain upstream
204+
or modified bytes and omit those packages from same-run VEX. The read-only
205+
probe covers Poetry virtualenvs, repeats on re-scans, and uses persisted patch
206+
records if fetching fresh records fails.
207+
203208
- **Agent mode finds Poetry's out-of-tree virtualenv.** Poetry keeps a
204209
project's virtualenv under `{cache-dir}/virtualenvs/<name>-<hash>-py<X.Y>`
205210
by default, so after a plain `poetry install` the crawler saw no

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ The rewriter reads a fixed set of candidate files from the project root: the npm
140140

141141
The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts.
142142

143+
**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`.
144+
143145
### Embedded VEX (`apply --vex` / `scan --vex` / `vendor --vex`)
144146

145147
`--vex <path>` folds OpenVEX 0.2.0 generation into `apply`, `scan`, and `vendor`: on a successful run the command writes the document to `<path>` 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.
@@ -1069,6 +1071,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
10691071
| `vendor_uuid_mismatch` | `skipped` | repair: the manifest's patch uuid moved past the vendored artifact — a re-vendor (`vendor` / `scan --vendor`) is pending; repair does not cross patch generations. |
10701072
| `content_mismatch_overwritten` | `skipped` (warning) | apply (default policy): a file matched NEITHER beforeHash nor afterHash and was overwritten with the full verified patched content. `--strict` turns this case into a `failed` event instead. |
10711073
| `vendor_lock_checksums_unsupported` / `vendor_stale_lock_checksum` | `failed` | vendor (gem): an ambiguous/platform CHECKSUMS entry, or a v1-wired lock whose stale token blocks the hot path (run `vendor --revert` + re-vendor). |
1074+
| `redirect_pypi_stale_install` | `redirect.warnings[]` (warning) | Hosted Python redirect: readable installed files differ from patched hashes. Read-only, repeated on re-scan, and excludes the package from same-run VEX. See the "Python stale-install guard" section. |
10721075
| `redirect_gem_stale_install` | `redirect.warnings[]` (warning) | scan `--mode hosted` (gem): a stale UNPATCHED materialization (installed gem, or committed `vendor/cache` archive) that `bundle install` will reuse instead of fetching the redirected patch; the detail carries the verified remedy. Full rules and flavors: the "Gem stale-install guard" section. |
10731076
| `pypi_{poetry,pdm,pipenv}_no_lockfile` | `failed` | vendor (pypi): a lock-less tool marker with no `requirements.txt` fallback — run `<tool> lock`. |
10741077
| `pypi_poetry_integrity_unverified` | `skipped` (warning) | vendor (pypi / poetry): the lock was written by Poetry < 1.4 (0.12 `[metadata.hashes]`, lock 1.0/1.1, or a 2.0 lock without a `@generated by Poetry X.Y.Z` header — 1.3 wrote those). That installer does not verify local wheel hashes (the committed wheel bytes are the protection) and does not replace an already-installed package at the same version; recreate the virtualenv or `pip uninstall` the package before `poetry install`, or upgrade Poetry. |

‎crates/socket-patch-cli/src/commands/scan/hosted.rs‎

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ use crate::commands::vex::generate_vex_from_manifest_path;
1111

1212
use super::{discover_selected, ScanArgs};
1313

14+
mod python;
15+
1416
/// Candidate lockfiles / registry configs the redirect rewriters may touch —
1517
/// read from the project when present and handed to `rewrite_registry_redirect`.
1618
/// Fragment-edit kinds whose lockfile the package manager re-lays in place
@@ -392,14 +394,13 @@ fn build_redirect_json_envelope(
392394
result
393395
}
394396

395-
/// The gem stale-install probe's outcome: warnings for both output channels,
397+
/// The installed-tree probes' outcome: warnings for both output channels,
396398
/// plus the stale purls STRUCTURALLY, so the same-run `--vex` can exclude
397399
/// them from `assume_applied` — an envelope must never attest a CVE its own
398-
/// warnings say is live. Excluded purls fall back to `vex`'s normal
399-
/// installed-tree verification: a patched install still attests (with hash
400-
/// evidence), a stale one is omitted.
400+
/// warnings say is live. Python also carries positive evidence through VEX
401+
/// so a different, healthy interpreter cannot mask a stale installation.
401402
#[derive(Default)]
402-
struct GemStaleOutcome {
403+
struct StaleInstallOutcome {
403404
warnings: Vec<serde_json::Value>,
404405
stale_purls: std::collections::BTreeSet<String>,
405406
}
@@ -493,13 +494,13 @@ fn gem_stale_cache_warning(purl: &str, cache_path: &Path) -> serde_json::Value {
493494
/// already-patched install must not produce a delete prescription.
494495
/// (`current_hash` is `Some` only when the bytes were really hashed, which
495496
/// also excludes the absent-new-file `Ready`.)
496-
async fn gem_stale_positive_evidence(
497-
gem_dir: &Path,
497+
async fn installed_stale_positive_evidence(
498+
package_dir: &Path,
498499
record: &socket_patch_core::manifest::schema::PatchRecord,
499500
) -> bool {
500501
use socket_patch_core::patch::apply::{verify_file_patch, VerifyStatus};
501502
for (file_name, info) in &record.files {
502-
let result = verify_file_patch(gem_dir, file_name, info).await;
503+
let result = verify_file_patch(package_dir, file_name, info).await;
503504
if matches!(
504505
result.status,
505506
VerifyStatus::Ready | VerifyStatus::HashMismatch
@@ -533,7 +534,7 @@ async fn gem_stale_positive_evidence(
533534
/// Judgments are grouped BY INSTALLED DIR: platform-variant purls of one
534535
/// gem resolve to the same dir, and if ANY variant's record proves the
535536
/// dir patched, the dir is patched — never warned.
536-
/// * STALE requires [`gem_stale_positive_evidence`] — never inferred from
537+
/// * STALE requires [`installed_stale_positive_evidence`] — never inferred from
537538
/// missing/unreadable files.
538539
/// * A committed `vendor/cache/<leaf>.gem` whose sha256 differs from the
539540
/// patched artifact's is stale too (bundler installs from it first, fresh
@@ -553,14 +554,14 @@ async fn gem_stale_install_warnings(
553554
socket_patch_core::manifest::schema::PatchRecord,
554555
>,
555556
gem_artifact_shas: &std::collections::BTreeMap<(String, String), String>,
556-
) -> GemStaleOutcome {
557+
) -> StaleInstallOutcome {
557558
use socket_patch_core::crawlers::types::CrawlerOptions;
558559
use socket_patch_core::crawlers::RubyCrawler;
559560
use socket_patch_core::manifest::schema::PatchRecord;
560561
use socket_patch_core::vendor::file_sha256_hex;
561562
use socket_patch_core::vex::verify::verify_patch_record;
562563

563-
let mut out = GemStaleOutcome::default();
564+
let mut out = StaleInstallOutcome::default();
564565
let find_record = |uuid: &str| -> Option<&PatchRecord> {
565566
records
566567
.values()
@@ -624,7 +625,7 @@ async fn gem_stale_install_warnings(
624625
});
625626
if verify_patch_record(&pkg.path, record).await.is_ok() {
626627
entry.patched = true;
627-
} else if !entry.positive && gem_stale_positive_evidence(&pkg.path, record).await {
628+
} else if !entry.positive && installed_stale_positive_evidence(&pkg.path, record).await {
628629
entry.positive = true;
629630
entry.purl = (*purl).to_string();
630631
}
@@ -1933,8 +1934,8 @@ pub(crate) async fn run_redirect_selected(
19331934
// the probe's ledger-record fallback could still judge an
19341935
// already-redirected project, so without this gate a dry-run would warn
19351936
// about state the run did not (re)create.
1936-
let gem_stale: GemStaleOutcome = if common.dry_run {
1937-
GemStaleOutcome::default()
1937+
let gem_stale: StaleInstallOutcome = if common.dry_run {
1938+
StaleInstallOutcome::default()
19381939
} else {
19391940
// purl-coordinate → the PATCHED .gem artifact's sha256 (registry
19401941
// override identifier, tarball integrity fallback) — judges a
@@ -1963,6 +1964,12 @@ pub(crate) async fn run_redirect_selected(
19631964
.await
19641965
};
19651966

1967+
let python_stale = if common.dry_run {
1968+
StaleInstallOutcome::default()
1969+
} else {
1970+
python::stale_install_warnings(common, &confirmed, &records, &ledger_records).await
1971+
};
1972+
19661973
// Cross-mode takeover: a committed vendored ledger (`.socket/vendor/state.json`)
19671974
// may still claim package(s) this project also has a hosted redirect ledger
19681975
// for — their tarballs would then be orphaned and that ledger stale. But the
@@ -2022,8 +2029,13 @@ pub(crate) async fn run_redirect_selected(
20222029
params.assume_applied = confirmed
20232030
.iter()
20242031
.map(|(purl, _)| purl.clone())
2025-
.filter(|purl| !gem_stale.stale_purls.contains(purl))
2032+
.filter(|purl| {
2033+
!gem_stale.stale_purls.contains(purl) && !python_stale.stale_purls.contains(purl)
2034+
})
20262035
.collect();
2036+
// A healthy copy in another interpreter must not override a stale
2037+
// Python tree found by the probe, including with --vex-no-verify.
2038+
params.known_stale = python_stale.stale_purls.iter().cloned().collect();
20272039
let manifest_path = common.resolved_manifest_path();
20282040
match generate_vex_from_manifest_path(common, &params, &manifest_path).await {
20292041
Ok(summary) => vex_statements = Some(summary.statements),
@@ -2049,6 +2061,7 @@ pub(crate) async fn run_redirect_selected(
20492061
warnings.extend(rush_warnings.iter().cloned());
20502062
warnings.extend(pnpm_warnings.iter().cloned());
20512063
warnings.extend(gem_stale.warnings.iter().cloned());
2064+
warnings.extend(python_stale.warnings.iter().cloned());
20522065
warnings.extend(takeover_pre_warnings.iter().cloned());
20532066
warnings.extend(takeover_warnings.iter().cloned());
20542067
warnings.extend(prune_warnings.iter().cloned());
@@ -2126,7 +2139,7 @@ pub(crate) async fn run_redirect_selected(
21262139
for w in &pnpm_warnings {
21272140
eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default());
21282141
}
2129-
for w in &gem_stale.warnings {
2142+
for w in gem_stale.warnings.iter().chain(&python_stale.warnings) {
21302143
// Code included: the stale-install hazard is a silent-CVE
21312144
// state, so the stderr line must be greppable by its stable
21322145
// code in CI logs, same as the JSON envelope.
@@ -2196,7 +2209,7 @@ pub(crate) fn boxed_run_redirect_selected<'a>(
21962209
mod tests {
21972210
use super::{
21982211
build_redirect_json_envelope, gem_stale_cache_warning, gem_stale_install_warning,
2199-
gem_stale_install_warnings, gem_stale_positive_evidence, parse_purl_simple,
2212+
gem_stale_install_warnings, installed_stale_positive_evidence, parse_purl_simple,
22002213
plan_workspace_trust, pnpm_heal_root, pnpm_lock_carries_hosted_redirect,
22012214
pnpm_lock_version_major, pnpm_trust_configured_detail, pnpm_trust_legacy_detail,
22022215
pnpm_trust_manual_guidance, pnpm_trust_workspace_unreadable_detail,
@@ -2748,7 +2761,7 @@ mod tests {
27482761
cwd: &std::path::Path,
27492762
confirmed: &[(String, String)],
27502763
records: &std::collections::BTreeMap<String, PatchRecord>,
2751-
) -> super::GemStaleOutcome {
2764+
) -> super::StaleInstallOutcome {
27522765
gem_stale_install_warnings(
27532766
cwd,
27542767
false,
@@ -2856,38 +2869,38 @@ mod tests {
28562869
/// transiently unreadable file in a patched install must not produce
28572870
/// a delete prescription.
28582871
#[tokio::test]
2859-
async fn gem_stale_positive_evidence_requires_readable_mismatched_bytes() {
2872+
async fn installed_stale_positive_evidence_requires_readable_mismatched_bytes() {
28602873
let tmp = tempfile::tempdir().unwrap();
28612874
let record = gem_record();
28622875

28632876
// Pristine upstream bytes → evidence.
28642877
let upstream = tmp.path().join("upstream");
28652878
std::fs::create_dir_all(upstream.join("lib")).unwrap();
28662879
std::fs::write(upstream.join("lib").join("stale_unit.rb"), GEM_UPSTREAM).unwrap();
2867-
assert!(gem_stale_positive_evidence(&upstream, &record).await);
2880+
assert!(installed_stale_positive_evidence(&upstream, &record).await);
28682881

28692882
// Tampered bytes (neither hash) → evidence.
28702883
let tampered = tmp.path().join("tampered");
28712884
std::fs::create_dir_all(tampered.join("lib")).unwrap();
28722885
std::fs::write(tampered.join("lib").join("stale_unit.rb"), b"other").unwrap();
2873-
assert!(gem_stale_positive_evidence(&tampered, &record).await);
2886+
assert!(installed_stale_positive_evidence(&tampered, &record).await);
28742887

28752888
// Patched bytes → no evidence.
28762889
let patched = tmp.path().join("patched");
28772890
std::fs::create_dir_all(patched.join("lib")).unwrap();
28782891
std::fs::write(patched.join("lib").join("stale_unit.rb"), GEM_PATCHED).unwrap();
2879-
assert!(!gem_stale_positive_evidence(&patched, &record).await);
2892+
assert!(!installed_stale_positive_evidence(&patched, &record).await);
28802893

28812894
// Missing file → no evidence (never a guess).
28822895
let hollow = tmp.path().join("hollow");
28832896
std::fs::create_dir_all(hollow.join("lib")).unwrap();
2884-
assert!(!gem_stale_positive_evidence(&hollow, &record).await);
2897+
assert!(!installed_stale_positive_evidence(&hollow, &record).await);
28852898

28862899
// A DIRECTORY at the file path (the unreadable-NotFound class) →
28872900
// no evidence.
28882901
let blocked = tmp.path().join("blocked");
28892902
std::fs::create_dir_all(blocked.join("lib").join("stale_unit.rb")).unwrap();
2890-
assert!(!gem_stale_positive_evidence(&blocked, &record).await);
2903+
assert!(!installed_stale_positive_evidence(&blocked, &record).await);
28912904

28922905
// Absent new-file (empty beforeHash routes to Ready with NO
28932906
// current_hash) → no evidence.
@@ -2897,7 +2910,7 @@ mod tests {
28972910
.get_mut("lib/stale_unit.rb")
28982911
.expect("fixture file entry")
28992912
.before_hash = String::new();
2900-
assert!(!gem_stale_positive_evidence(&hollow, &new_file).await);
2913+
assert!(!installed_stale_positive_evidence(&hollow, &new_file).await);
29012914
}
29022915

29032916
/// The probe end to end over a real deployment layout: a STALE

0 commit comments

Comments
 (0)