diff --git a/.github/tests/deploy-static-lock-validator.test.rb b/.github/tests/deploy-static-lock-validator.test.rb index 917c8a8..78631f8 100644 --- a/.github/tests/deploy-static-lock-validator.test.rb +++ b/.github/tests/deploy-static-lock-validator.test.rb @@ -193,6 +193,119 @@ resolution: integrity: sha512-safe YAML + "frozen 0.154 renderer alias" => [<<~YAML, true], + lockfileVersion: '9.0' + importers: + .: + dependencies: + '@cellarnode/ui-renderer-0-154': + specifier: npm:@cellarnode/ui@0.154.0 + version: '@cellarnode/ui@0.154.0(react@19.1.1)' + YAML + "frozen 0.154 renderer alias with wrong version" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + '@cellarnode/ui-renderer-0-154': + specifier: npm:@cellarnode/ui@0.154.1 + version: '@cellarnode/ui@0.154.1' + YAML + "frozen 0.155 renderer alias" => [<<~YAML, true], + lockfileVersion: '9.0' + importers: + .: + dependencies: + '@cellarnode/ui-renderer-0-155': + specifier: npm:@cellarnode/ui@0.155.1 + version: '@cellarnode/ui@0.155.1(react@19.1.1)' + packages: + '@cellarnode/ui@0.155.1': + resolution: + integrity: sha512-safe + YAML + "frozen 0.155 renderer alias with hashed peer context" => [<<~YAML, true], + lockfileVersion: '9.0' + importers: + .: + dependencies: + '@cellarnode/ui-renderer-0-155': + specifier: npm:@cellarnode/ui@0.155.1 + version: '@cellarnode/ui@0.155.1(e4ae7f10a11970144bc87c4d16ad3111)' + YAML + "frozen 0.155 renderer alias with wrong version" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + '@cellarnode/ui-renderer-0-155': + specifier: npm:@cellarnode/ui@9.9.9 + version: '@cellarnode/ui@9.9.9' + YAML + "frozen 0.155 renderer alias with wrong resolved version" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + '@cellarnode/ui-renderer-0-155': + specifier: npm:@cellarnode/ui@0.155.1 + version: '@cellarnode/ui@9.9.9' + YAML + "frozen 0.155 renderer alias with protocol peer context" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + '@cellarnode/ui-renderer-0-155': + specifier: npm:@cellarnode/ui@0.155.1 + version: '@cellarnode/ui@0.155.1(file:../evil)' + YAML + "frozen 0.155 renderer alias with traversal peer context" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + '@cellarnode/ui-renderer-0-155': + specifier: npm:@cellarnode/ui@0.155.1 + version: '@cellarnode/ui@0.155.1(react@19.1.1/../../evil)' + YAML + "frozen 0.155 renderer alias with empty peer context" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + '@cellarnode/ui-renderer-0-155': + specifier: npm:@cellarnode/ui@0.155.1 + version: '@cellarnode/ui@0.155.1()' + YAML + "frozen 0.155 renderer alias with wrong target" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + '@cellarnode/ui-renderer-0-155': + specifier: npm:@cellarnode/auth@1.0.0 + version: '@cellarnode/auth@1.0.0' + YAML + "frozen 0.155 renderer as direct lock package" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: + '@cellarnode/ui-renderer-0-155@1.0.0': + resolution: + integrity: sha512-safe + YAML + "nested frozen 0.155 renderer lock alias key" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + wrapper: + dependencies: + '@cellarnode/ui-renderer-0-155': + specifier: npm:@cellarnode/ui@0.155.1 + version: '@cellarnode/ui@0.155.1' + YAML "unknown private package" => [<<~YAML, false], lockfileVersion: '9.0' importers: {} @@ -260,6 +373,120 @@ "aliased unknown private manifest package" => ["hidden-package", "npm:@cellarnode/internal-secrets@1.0.0", false], "allowed private manifest package" => ["@cellarnode/ui", "1.0.0", true], "frozen renderer registry alias" => ["@cellarnode/ui-renderer-0-154", "npm:@cellarnode/ui@0.154.0", true], + "frozen 0.154 renderer alias with wrong version" => ["@cellarnode/ui-renderer-0-154", "npm:@cellarnode/ui@0.154.1", false], + "frozen 0.155 renderer registry alias" => ["@cellarnode/ui-renderer-0-155", "npm:@cellarnode/ui@0.155.1", true], + "frozen 0.155 renderer alias with wrong version" => ["@cellarnode/ui-renderer-0-155", "npm:@cellarnode/ui@9.9.9", false], + "frozen 0.155 renderer alias with wrong target" => ["@cellarnode/ui-renderer-0-155", "npm:@cellarnode/auth@1.0.0", false], + "frozen 0.155 renderer alias with plain version" => ["@cellarnode/ui-renderer-0-155", "1.0.0", false], + "nested frozen 0.155 renderer alias" => ["@cellarnode/ui", "npm:@cellarnode/ui-renderer-0-155@1.0.0", false], + "case-variant frozen 0.155 renderer alias" => ["@cellarnode/UI-renderer-0-155", "npm:@cellarnode/ui@0.155.1", false], + "nested frozen 0.155 renderer alias key" => [ + "unused", + "unused", + false, + nil, + <<~JSON, + { + "dependencies": { + "wrapper": { + "@cellarnode/ui-renderer-0-155": "npm:@cellarnode/ui@0.155.1" + } + } + } + JSON + ], + "frozen 0.155 renderer alias in pnpm overrides" => [ + "unused", + "unused", + false, + nil, + <<~JSON, + { + "dependencies": { "public-package": "1.0.0" }, + "pnpm": { + "overrides": { + "@cellarnode/ui-renderer-0-155": "npm:@cellarnode/ui@0.155.1" + } + } + } + JSON + ], + "frozen 0.155 renderer alias in package extension" => [ + "unused", + "unused", + false, + nil, + <<~JSON, + { + "dependencies": { "public-package": "1.0.0" }, + "pnpm": { + "packageExtensions": { + "public-package@1.0.0": { + "dependencies": { + "@cellarnode/ui-renderer-0-155": "npm:@cellarnode/ui@0.155.1" + } + } + } + } + } + JSON + ], + "frozen 0.155 renderer alias in root override" => [ + "unused", + "unused", + false, + nil, + <<~JSON, + { + "dependencies": { "public-package": "1.0.0" }, + "overrides": { + "@cellarnode/ui-renderer-0-155": "npm:@cellarnode/ui@0.155.1" + } + } + JSON + ], + "frozen 0.155 renderer alias in root package extension" => [ + "unused", + "unused", + false, + nil, + <<~JSON, + { + "dependencies": { "public-package": "1.0.0" }, + "packageExtensions": { + "@cellarnode/ui-renderer-0-155": "npm:@cellarnode/ui@0.155.1" + } + } + JSON + ], + "frozen 0.155 renderer alias in root resolution" => [ + "unused", + "unused", + false, + nil, + <<~JSON, + { + "dependencies": { "public-package": "1.0.0" }, + "resolutions": { + "@cellarnode/ui-renderer-0-155": "npm:@cellarnode/ui@0.155.1" + } + } + JSON + ], + "frozen 0.155 renderer alias in root catalog" => [ + "unused", + "unused", + false, + nil, + <<~JSON, + { + "dependencies": { "public-package": "1.0.0" }, + "catalog": { + "@cellarnode/ui-renderer-0-155": "npm:@cellarnode/ui@0.155.1" + } + } + JSON + ], "frozen renderer alias to public target" => ["@cellarnode/ui-renderer-0-154", "npm:leftpad@1.0.0", false], "allowed alias with path traversal version" => ["@cellarnode/ui", "npm:@cellarnode/ui@0.154.0/../../evil", false], "allowed public registry range" => ["public-package", "^1.0.0", true], @@ -350,11 +577,11 @@ } dependency_scripts.each do |job_name, (validation_script, non_frozen_script)| - manifest_fixtures.each do |name, (package, version, expected_install, workspace)| + manifest_fixtures.each do |name, (package, version, expected_install, workspace, manifest)| Dir.mktmpdir("cel1328-manifest-boundary") do |directory| File.write( File.join(directory, "package.json"), - <<~JSON, + manifest || <<~JSON, { "dependencies": { "#{package}": "#{version}" diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 9935b30..5375a06 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -181,23 +181,32 @@ jobs: peerDependencies resolutions ].freeze + direct_dependency_sections = %w[ + dependencies + devDependencies + optionalDependencies + peerDependencies + ].freeze protocol_dependency_source = /\A[a-z][a-z0-9+.-]*:/i path_or_archive_dependency_source = /[\\\/]|\.(?:tgz|tar(?:\.gz)?)\z/i - allowed_private_packages = %w[ + canonical_private_packages = %w[ auth beverage-utils elabel-compliance finance i18n ui - ui-renderer-0-154 ].freeze + frozen_registry_aliases = { + "@cellarnode/ui-renderer-0-154" => "npm:@cellarnode/ui@0.154.0", + "@cellarnode/ui-renderer-0-155" => "npm:@cellarnode/ui@0.155.1", + }.freeze # A version-pinned npm: alias is registry-sourced, but only aliases # that TARGET an allowed @cellarnode package are exempt from the # protocol check (e.g. @cellarnode/ui-renderer-0-154 -> # npm:@cellarnode/ui@0.154.0, the frozen e-label public renderer, # CEL-1522). An npm: alias pointing anywhere else still fails. - allowed_registry_alias = /\Anpm:@cellarnode\/(?:#{Regexp.union(allowed_private_packages).source})@[^\s\/\\]+\z/ + allowed_registry_alias = /\Anpm:@cellarnode\/(?:#{Regexp.union(canonical_private_packages).source})@[^\s\/\\]+\z/ failures = [] manifests = [] Find.find(".") do |path| @@ -218,15 +227,22 @@ jobs: value.each do |key, child| child_keys = keys + [key.to_s] key.to_s.scan(private_package).flatten.each do |package| - failures << "#{path}:#{child_keys.join('.')}" unless allowed_private_packages.include?(package.downcase) + alias_name = "@cellarnode/#{package.downcase}" + allowed_frozen_alias_key = frozen_registry_aliases.key?(alias_name) && + child_keys.length == 2 && + direct_dependency_sections.include?(child_keys.first) && + child_keys.last == alias_name + failures << "#{path}:#{child_keys.join('.')}" unless canonical_private_packages.include?(package.downcase) || allowed_frozen_alias_key end + expected_alias = frozen_registry_aliases[key.to_s] + failures << "#{path}:#{child_keys.join('.')}" if expected_alias && child != expected_alias visit.call(child, path, child_keys) end when Array value.each_with_index { |child, index| visit.call(child, path, keys + [index.to_s]) } when String value.scan(private_package).flatten.each do |package| - failures << "#{path}:#{keys.join('.')}" unless allowed_private_packages.include?(package.downcase) + failures << "#{path}:#{keys.join('.')}" unless canonical_private_packages.include?(package.downcase) end # An allowed registry alias is exempt from BOTH source checks: its # scope slash would otherwise trip the path heuristic. @@ -265,19 +281,74 @@ jobs: repository_shorthand = /\A[a-z0-9._-]+(?:[\\\/][a-z0-9._-]+)+(?:#.*)?\z/i private_package = /@cellarnode\/([a-z0-9._-]+)/i dependency_sections = %w[dependencies devDependencies optionalDependencies peerDependencies].freeze - allowed_private_packages = %w[ + canonical_private_packages = %w[ auth beverage-utils elabel-compliance finance i18n ui - ui-renderer-0-154 ].freeze + peer_locator = /\G(?:@[a-z0-9._-]+\/[a-z0-9._-]+|[a-z0-9._-]+)@[0-9a-z][0-9a-z.+_-]*/ + parse_peer_group = nil + parse_peer_group = lambda do |text, index| + next nil unless text[index] == "(" + + index += 1 + locator = peer_locator.match(text, index) + next nil unless locator + + index = locator.end(0) + valid = true + while valid && text[index] == "(" + nested_end = parse_peer_group.call(text, index) + if nested_end + index = nested_end + else + valid = false + end + end + next nil unless valid + next nil unless text[index] == ")" + + index + 1 + end + valid_peer_suffix = lambda do |suffix| + next true if suffix.empty? + next true if suffix.match?(/\A\([a-f0-9]{32}\)\z/) + + index = 0 + valid = true + while valid && index < suffix.length + group_end = parse_peer_group.call(suffix, index) + if group_end + index = group_end + else + valid = false + end + end + valid && index.positive? + end + frozen_registry_aliases = { + "@cellarnode/ui-renderer-0-154" => { + "specifier" => "npm:@cellarnode/ui@0.154.0", + "version" => "@cellarnode/ui@0.154.0", + }.freeze, + "@cellarnode/ui-renderer-0-155" => { + "specifier" => "npm:@cellarnode/ui@0.155.1", + "version" => "@cellarnode/ui@0.155.1", + }.freeze, + }.freeze failures = [] validate_private_packages = lambda do |text, keys| text.to_s.scan(private_package).flatten.each do |package| - failures << keys.join(".") unless allowed_private_packages.include?(package.downcase) + alias_name = "@cellarnode/#{package.downcase}" + allowed_frozen_alias_key = frozen_registry_aliases.key?(alias_name) && + keys.length == 4 && + keys[0] == "importers" && + dependency_sections.include?(keys[2]) && + keys[3] == alias_name + failures << keys.join(".") unless canonical_private_packages.include?(package.downcase) || allowed_frozen_alias_key end end visit = lambda do |value, keys| @@ -287,6 +358,21 @@ jobs: name = key.to_s child_keys = keys + [name] validate_private_packages.call(name, child_keys) + expected_alias = frozen_registry_aliases[name] + if expected_alias + resolved_version = child["version"] if child.is_a?(Hash) + expected_version = expected_alias.fetch("version") + valid_version = resolved_version.is_a?(String) && + resolved_version.start_with?(expected_version) && + valid_peer_suffix.call(resolved_version.delete_prefix(expected_version)) + valid_alias = keys.length == 3 && + keys[0] == "importers" && + dependency_sections.include?(keys[2]) && + child.is_a?(Hash) && + child["specifier"] == expected_alias.fetch("specifier") && + valid_version + failures << child_keys.join(".") unless valid_alias + end if name == "resolution" valid_resolution = child.is_a?(Hash) && child.keys.map(&:to_s) == ["integrity"] &&