diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 0000000..724ccbb --- /dev/null +++ b/.semgrepignore @@ -0,0 +1,5 @@ +# Byte-exact test fixture corpus: Semgrep findings on curated fixture content +# (e.g., pnpm cooldown policy values inside testdata) are categorically false +# positives. Fixtures mirror real caller files for parser validation and are +# never executed or shipped. Production policy files remain fully scanned. +testdata/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b24a47..eebbe16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ Human Era five-digit years (e.g., `## [0.1.0] - 12026-06-13`). feature tables, an expanded security and trust model, badge usage documentation, contributing links, and a license section. +### Fixed + +- Fixed pnpm package resolution for standalone root packages whose `pnpm-workspace.yaml` contains + policy settings but omits the optional `packages` member. + ### Security - Raised the module Go directive to 1.26.5, clearing exposure to diff --git a/internal/fixture/npm_fixtures_test.go b/internal/fixture/npm_fixtures_test.go index 457b2e3..1f0f7eb 100644 --- a/internal/fixture/npm_fixtures_test.go +++ b/internal/fixture/npm_fixtures_test.go @@ -19,22 +19,25 @@ func TestNPMFixturesNeverPublish(t *testing.T) { } wantCases := map[string]string{ - "npm-root-valid": "", - "pnpm-root-valid": "", - "yarn-v4-root-valid": "", - "npm-workspace-valid": "", - "npm-stale-lockfiles-valid": "", - "pnpm-stale-lockfiles-valid": "", - "yarn-stale-lockfiles-valid": "", - "lockfile-only-ambiguous": "windlass.verify.error.package-manager-conflict", - "selected-lockfile-missing": "windlass.verify.error.required-lockfile-missing", - "pnpm-version-missing": "windlass.verify.error.package-manager-version-required", - "yarn-classic-rejected": "windlass.verify.error.yarn-selection-invalid", - "yarn-devengines-rejected": "windlass.verify.error.yarn-selection-invalid", - "private-package-rejected": "windlass.verify.error.package-private", - "package-metadata-missing-rejected": "windlass.verify.error.package-metadata-required", - "unsupported-manager-rejected": "windlass.verify.error.package-manager-conflict", - "yarn-lockfile-only-rejected": "windlass.verify.error.yarn-selection-invalid", + "npm-root-valid": "", + "pnpm-root-valid": "", + "pnpm-settings-only-root-valid": "", + "yarn-v4-root-valid": "", + "npm-workspace-valid": "", + "npm-stale-lockfiles-valid": "", + "pnpm-stale-lockfiles-valid": "", + "yarn-stale-lockfiles-valid": "", + "lockfile-only-ambiguous": "windlass.verify.error.package-manager-conflict", + "selected-lockfile-missing": "windlass.verify.error.required-lockfile-missing", + "pnpm-version-missing": "windlass.verify.error.package-manager-version-required", + "yarn-classic-rejected": "windlass.verify.error.yarn-selection-invalid", + "yarn-devengines-rejected": "windlass.verify.error.yarn-selection-invalid", + "private-package-rejected": "windlass.verify.error.package-private", + "package-metadata-missing-rejected": "windlass.verify.error.package-metadata-required", + "unsupported-manager-rejected": "windlass.verify.error.package-manager-conflict", + "yarn-lockfile-only-rejected": "windlass.verify.error.yarn-selection-invalid", + "pnpm-workspace-packages-wrong-type": "windlass.verify.error.package-resolution-invalid", + "pnpm-settings-only-subdirectory": "windlass.verify.error.package-resolution-invalid", } for _, manifest := range index.Fixtures { diff --git a/internal/fixture/registry.go b/internal/fixture/registry.go index bb834ce..8ff6530 100644 --- a/internal/fixture/registry.go +++ b/internal/fixture/registry.go @@ -12,6 +12,7 @@ var requirementRegistry = map[string]string{ "ADR-0017.exact-package-manager-version": "pnpm and Yarn require exact release versions", "ADR-0018.single-workspace-package": "one explicitly selected workspace package per run", "ADR-0019.package-publish-metadata": "package identity and private publish intent validation", + "ADR-0078.pnpm-settings-only-root-package": "settings-only pnpm metadata selects only the standalone root package", "ADR-0056.stale-lockfile-diagnostics": "non-selected lockfiles are recorded stale diagnostics", "ADR-0056.stale-lockfile-rejections": "ambiguous and missing selected lockfiles are rejected", "ADR-0063.yarn-v4-selection": "Yarn requires top-level exact Berry v4 packageManager metadata", @@ -28,6 +29,7 @@ var failureCategoryRegistry = map[string]struct{}{ "package-manager-version-required": {}, "package-metadata-required": {}, "package-private": {}, + "package-resolution-invalid": {}, "policy-schema-invalid": {}, "required-lockfile-missing": {}, "yarn-selection-invalid": {}, @@ -35,14 +37,15 @@ var failureCategoryRegistry = map[string]struct{}{ var fixturePhaseRequirements = map[string]map[string]struct{}{ "build-pack": { - "ADR-0014.package-manager-matrix": {}, - "ADR-0017.exact-package-manager-version": {}, - "ADR-0018.single-workspace-package": {}, - "ADR-0019.package-publish-metadata": {}, - "ADR-0056.stale-lockfile-diagnostics": {}, - "ADR-0056.stale-lockfile-rejections": {}, - "ADR-0063.yarn-v4-selection": {}, - "ADR-0064.npm-purl-subject-digests": {}, + "ADR-0014.package-manager-matrix": {}, + "ADR-0017.exact-package-manager-version": {}, + "ADR-0018.single-workspace-package": {}, + "ADR-0019.package-publish-metadata": {}, + "ADR-0056.stale-lockfile-diagnostics": {}, + "ADR-0056.stale-lockfile-rejections": {}, + "ADR-0063.yarn-v4-selection": {}, + "ADR-0064.npm-purl-subject-digests": {}, + "ADR-0078.pnpm-settings-only-root-package": {}, }, "provenance-inputs": { "ARCH-js-ts-npm-provenance-publish.signing-inputs": {}, diff --git a/internal/npmprofile/selection_test.go b/internal/npmprofile/selection_test.go index 3877f9d..75c9c22 100644 --- a/internal/npmprofile/selection_test.go +++ b/internal/npmprofile/selection_test.go @@ -116,6 +116,21 @@ func TestWorkspaceDiscovery(t *testing.T) { t.Fatalf("manager selection = %#v", result.Manager) } + t.Run("pnpm settings-only root", func(t *testing.T) { + t.Parallel() + result := analyzeFixture(t, testRepositoryRoot(t), "testdata/npm/packages/pnpm-settings-only-valid") + assertPass(t, result) + if result.Package.Directory != "." || result.Package.ManagerRoot != "." { + t.Fatalf("standalone root package = %#v", result.Package) + } + }) + + t.Run("pnpm settings-only subdirectory fails closed", func(t *testing.T) { + t.Parallel() + result := analyzeFixture(t, testRepositoryRoot(t), "testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/packages/undeclared") + assertRejected(t, result, IDPackageResolutionInvalid) + }) + t.Run("pnpm recursive pattern", func(t *testing.T) { t.Parallel() root := createRepository(t, map[string]string{ @@ -343,6 +358,10 @@ func analyzeFixture(t *testing.T, repositoryRoot, packageDirectory string) Resul } if len(parts) > 0 && parts[0] == "rejected" { fixtureRootParts = 2 + selectedDirectory = strings.Join(parts[2:], "/") + if selectedDirectory == "" { + selectedDirectory = "." + } } if len(parts) > 0 && parts[0] == "workspace-valid" { fixtureRootParts = 1 diff --git a/internal/npmprofile/workspace.go b/internal/npmprofile/workspace.go index ecc507c..adaee98 100644 --- a/internal/npmprofile/workspace.go +++ b/internal/npmprofile/workspace.go @@ -13,7 +13,7 @@ import ( func discoverWorkspaceRoot(repositoryRoot, selectedDirectory string) (string, *manifest, error) { for candidate := selectedDirectory; ; candidate = filepath.Dir(candidate) { - patterns, candidateManifest, metadataPresent, err := workspacePatterns(candidate) + patterns, candidateManifest, metadataPresent, pnpmRootOnly, err := workspacePatterns(candidate) if err != nil { return "", nil, err } @@ -22,6 +22,12 @@ func discoverWorkspaceRoot(repositoryRoot, selectedDirectory string) (string, *m if relErr != nil { return "", nil, relErr } + if pnpmRootOnly { + if relative == "." { + return candidate, candidateManifest, nil + } + return "", nil, errors.New("settings-only pnpm workspace cannot claim a subdirectory") + } matched := false for _, pattern := range patterns { if matchWorkspacePattern(pattern, canonicalRelative(relative)) { @@ -43,48 +49,50 @@ func discoverWorkspaceRoot(repositoryRoot, selectedDirectory string) (string, *m return selectedDirectory, nil, nil } -func workspacePatterns(candidate string) ([]string, *manifest, bool, error) { +func workspacePatterns(candidate string) ([]string, *manifest, bool, bool, error) { var patterns []string var candidateManifest *manifest metadataPresent := false + pnpmRootOnly := false manifestPath := filepath.Join(candidate, "package.json") if _, err := os.Lstat(manifestPath); err == nil { decoded, _, readErr := readManifest(manifestPath) if readErr != nil { - return nil, nil, false, readErr + return nil, nil, false, false, readErr } candidateManifest = &decoded if len(decoded.Workspaces) != 0 { metadataPresent = true workspacePatterns, parseErr := parseJSONWorkspacePatterns(decoded.Workspaces) if parseErr != nil { - return nil, nil, false, parseErr + return nil, nil, false, false, parseErr } patterns = append(patterns, workspacePatterns...) } } else if !os.IsNotExist(err) { - return nil, nil, false, err + return nil, nil, false, false, err } pnpmPath := filepath.Join(candidate, "pnpm-workspace.yaml") if encoded, fileErr := readRegularNonSymlink(pnpmPath); fileErr == nil { metadataPresent = true - pnpmPatterns, parseErr := parsePNPMWorkspacePatterns(encoded) + pnpmPatterns, rootOnly, parseErr := parsePNPMWorkspacePatterns(encoded) if parseErr != nil { - return nil, nil, false, parseErr + return nil, nil, false, false, parseErr } + pnpmRootOnly = rootOnly patterns = append(patterns, pnpmPatterns...) } else if !os.IsNotExist(fileErr) { - return nil, nil, false, fileErr + return nil, nil, false, false, fileErr } for _, pattern := range patterns { if err := validateWorkspacePattern(pattern); err != nil { - return nil, nil, false, err + return nil, nil, false, false, err } } - return patterns, candidateManifest, metadataPresent, nil + return patterns, candidateManifest, metadataPresent, pnpmRootOnly, nil } func parseJSONWorkspacePatterns(raw jsonRaw) ([]string, error) { @@ -103,29 +111,29 @@ func parseJSONWorkspacePatterns(raw jsonRaw) ([]string, error) { return direct, nil } -func parsePNPMWorkspacePatterns(encoded []byte) ([]string, error) { +func parsePNPMWorkspacePatterns(encoded []byte) ([]string, bool, error) { var object map[string]any decoder := yaml.NewDecoder(bytes.NewReader(encoded), yaml.Strict()) if err := decoder.Decode(&object); err != nil || object == nil { - return nil, errors.New("pnpm workspace must be an object") + return nil, false, errors.New("pnpm workspace must be an object") } rawPackages, ok := object["packages"] if !ok { - return nil, errors.New("pnpm workspace packages are required") + return nil, true, nil } values, ok := rawPackages.([]any) if !ok { - return nil, errors.New("pnpm workspace packages must be an array") + return nil, false, errors.New("pnpm workspace packages must be an array") } patterns := make([]string, 0, len(values)) for _, value := range values { pattern, ok := value.(string) if !ok { - return nil, errors.New("pnpm workspace patterns must be strings") + return nil, false, errors.New("pnpm workspace patterns must be strings") } patterns = append(patterns, pattern) } - return patterns, nil + return patterns, false, nil } func validateWorkspacePattern(pattern string) error { diff --git a/testdata/fixtures/index.json b/testdata/fixtures/index.json index 246737f..4391d0c 100644 --- a/testdata/fixtures/index.json +++ b/testdata/fixtures/index.json @@ -39,6 +39,19 @@ "expected-secondary-ids": [], "covered-requirement": "ADR-0064.npm-purl-subject-digests" }, + { + "name": "pnpm-settings-only-root-valid", + "type": "accepted", + "surface": "npm", + "artifact": "testdata/npm/packages/pnpm-settings-only-valid/package.json", + "provenance": "testdata/npm/packages/pnpm-settings-only-valid/fixture.json", + "release-manifest": null, + "expected-result": "pass", + "expected-failure-category": null, + "expected-primary-id": null, + "expected-secondary-ids": [], + "covered-requirement": "ADR-0078.pnpm-settings-only-root-package" + }, { "name": "npm-provenance-predicate-jcs-valid", "type": "accepted", @@ -233,6 +246,32 @@ "expected-primary-id": "windlass.verify.error.yarn-selection-invalid", "expected-secondary-ids": [], "covered-requirement": "ADR-0063.yarn-v4-selection" + }, + { + "name": "pnpm-workspace-packages-wrong-type", + "type": "rejected", + "surface": "npm", + "artifact": "testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/package.json", + "provenance": "testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/fixture.json", + "release-manifest": null, + "expected-result": "fail", + "expected-failure-category": "package-resolution-invalid", + "expected-primary-id": "windlass.verify.error.package-resolution-invalid", + "expected-secondary-ids": [], + "covered-requirement": "ADR-0078.pnpm-settings-only-root-package" + }, + { + "name": "pnpm-settings-only-subdirectory", + "type": "rejected", + "surface": "npm", + "artifact": "testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/packages/undeclared/package.json", + "provenance": "testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/fixture.json", + "release-manifest": null, + "expected-result": "fail", + "expected-failure-category": "package-resolution-invalid", + "expected-primary-id": "windlass.verify.error.package-resolution-invalid", + "expected-secondary-ids": [], + "covered-requirement": "ADR-0078.pnpm-settings-only-root-package" } ] } diff --git a/testdata/npm/packages/pnpm-settings-only-valid/fixture.json b/testdata/npm/packages/pnpm-settings-only-valid/fixture.json new file mode 100644 index 0000000..c04e518 --- /dev/null +++ b/testdata/npm/packages/pnpm-settings-only-valid/fixture.json @@ -0,0 +1,18 @@ +{ + "phase": "build-pack", + "package-directory": "testdata/npm/packages/pnpm-settings-only-valid", + "package-manager": { + "name": "pnpm", + "version": "10.14.0", + "selection-source": "packageManager", + "selected-lockfile": "pnpm-lock.yaml", + "distribution-digest-authority": "registry-integrity" + }, + "commands": { + "install": ["pnpm", "install", "--frozen-lockfile"], + "build": [], + "pack": ["pnpm", "pack", "--pack-destination", ""] + }, + "expected-subject": "pkg:npm/%40windlass-fixtures/pnpm-settings-only@1.0.0", + "required-digests": ["sha512", "sha256"] +} diff --git a/testdata/npm/packages/pnpm-settings-only-valid/package.json b/testdata/npm/packages/pnpm-settings-only-valid/package.json new file mode 100644 index 0000000..483f7d5 --- /dev/null +++ b/testdata/npm/packages/pnpm-settings-only-valid/package.json @@ -0,0 +1,8 @@ +{ + "name": "@windlass-fixtures/pnpm-settings-only", + "version": "1.0.0", + "description": "Standalone pnpm package with workspace policy settings", + "license": "Apache-2.0", + "repository": "github:windlasstech/slsa-builder", + "packageManager": "pnpm@10.14.0" +} diff --git a/testdata/npm/packages/pnpm-settings-only-valid/pnpm-lock.yaml b/testdata/npm/packages/pnpm-settings-only-valid/pnpm-lock.yaml new file mode 100644 index 0000000..b6688e7 --- /dev/null +++ b/testdata/npm/packages/pnpm-settings-only-valid/pnpm-lock.yaml @@ -0,0 +1,8 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: {} diff --git a/testdata/npm/packages/pnpm-settings-only-valid/pnpm-workspace.yaml b/testdata/npm/packages/pnpm-settings-only-valid/pnpm-workspace.yaml new file mode 100644 index 0000000..95d3a08 --- /dev/null +++ b/testdata/npm/packages/pnpm-settings-only-valid/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +minimumReleaseAge: 1440 +minimumReleaseAgeExclude: + - '@windlass-fixtures/*' +onlyBuiltDependencies: + - esbuild +trustPolicy: no-downgrade diff --git a/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/fixture.json b/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/fixture.json new file mode 100644 index 0000000..3212f35 --- /dev/null +++ b/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/fixture.json @@ -0,0 +1 @@ +{"phase":"build-pack","package-directory":"testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/packages/undeclared","commands":{},"expected-primary-id":"windlass.verify.error.package-resolution-invalid"} diff --git a/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/package.json b/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/package.json new file mode 100644 index 0000000..06f11e4 --- /dev/null +++ b/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/package.json @@ -0,0 +1,7 @@ +{ + "name": "@windlass-fixtures/pnpm-settings-only-root", + "version": "1.0.0", + "private": true, + "repository": "github:windlasstech/slsa-builder", + "packageManager": "pnpm@10.14.0" +} diff --git a/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/packages/undeclared/package.json b/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/packages/undeclared/package.json new file mode 100644 index 0000000..6a78dff --- /dev/null +++ b/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/packages/undeclared/package.json @@ -0,0 +1,5 @@ +{ + "name": "@windlass-fixtures/undeclared-subdirectory", + "version": "1.0.0", + "repository": "github:windlasstech/slsa-builder" +} diff --git a/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/pnpm-lock.yaml b/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/pnpm-lock.yaml new file mode 100644 index 0000000..b6688e7 --- /dev/null +++ b/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/pnpm-lock.yaml @@ -0,0 +1,8 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: {} diff --git a/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/pnpm-workspace.yaml b/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/pnpm-workspace.yaml new file mode 100644 index 0000000..95d3a08 --- /dev/null +++ b/testdata/npm/packages/rejected/pnpm-settings-only-subdirectory/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +minimumReleaseAge: 1440 +minimumReleaseAgeExclude: + - '@windlass-fixtures/*' +onlyBuiltDependencies: + - esbuild +trustPolicy: no-downgrade diff --git a/testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/fixture.json b/testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/fixture.json new file mode 100644 index 0000000..083da5c --- /dev/null +++ b/testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/fixture.json @@ -0,0 +1 @@ +{"phase":"build-pack","package-directory":"testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type","commands":{},"expected-primary-id":"windlass.verify.error.package-resolution-invalid"} diff --git a/testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/package.json b/testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/package.json new file mode 100644 index 0000000..8822dce --- /dev/null +++ b/testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/package.json @@ -0,0 +1,6 @@ +{ + "name": "@windlass-fixtures/pnpm-workspace-packages-wrong-type", + "version": "1.0.0", + "repository": "github:windlasstech/slsa-builder", + "packageManager": "pnpm@10.14.0" +} diff --git a/testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/pnpm-lock.yaml b/testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/pnpm-lock.yaml new file mode 100644 index 0000000..b6688e7 --- /dev/null +++ b/testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/pnpm-lock.yaml @@ -0,0 +1,8 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: {} diff --git a/testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/pnpm-workspace.yaml b/testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/pnpm-workspace.yaml new file mode 100644 index 0000000..ca55285 --- /dev/null +++ b/testdata/npm/packages/rejected/pnpm-workspace-packages-wrong-type/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: packages/* +minimumReleaseAge: 1440 +trustPolicy: no-downgrade