diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/FrontendTools.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/FrontendTools.java index d21e7a96f15..b58d00c913a 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/FrontendTools.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/FrontendTools.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -612,26 +613,77 @@ Set getCustomNpmRegistries(File workingDirectory) { * map if the configuration cannot be read */ Map getConfiguredRegistries(File workingDirectory) { - List command = new ArrayList<>(getNpmExecutable(false)); + JsonNode config = getResolvedConfiguration(getNpmExecutable(false), + workingDirectory); + Map registries = new HashMap<>(); + for (String key : config.propertyNames()) { + if ((key.equals("registry") || key.endsWith(":registry")) + && config.get(key).isString()) { + registries.put(key, config.get(key).asString()); + } + } + return registries; + } + + /** + * Reads the value the given npm or pnpm command resolves for a + * configuration key. + * + * @param toolCommand + * the npm or pnpm command to run + * @param key + * the configuration key to read + * @param workingDirectory + * the directory the configuration is resolved from, so that a + * project {@code .npmrc} is taken into account + * @return the configured value, or an empty optional if the key is not + * configured, is unknown to the tool, or the configuration cannot + * be read + */ + Optional getConfiguredSetting(List toolCommand, String key, + File workingDirectory) { + JsonNode value = getResolvedConfiguration(toolCommand, workingDirectory) + .get(key); + // npm lists every key it knows, using null for the ones that are not + // configured; pnpm lists only the configured ones + if (value == null || value.isNull()) { + return Optional.empty(); + } + return Optional.of(value.asString()); + } + + /** + * Reads the configuration the given npm or pnpm command resolves for a + * directory by running {@code config ls --json}. + *

+ * The configuration is read from the tool itself, so it accounts for every + * configuration source and precedence rule the tool applies (command line, + * environment variables, project/user/global/builtin {@code .npmrc} and, + * for pnpm, {@code pnpm-workspace.yaml}). + * + * @param toolCommand + * the npm or pnpm command to run + * @param workingDirectory + * the directory the configuration is resolved from, so that a + * project {@code .npmrc} is taken into account + * @return the resolved configuration, or an empty object if it cannot be + * read + */ + JsonNode getResolvedConfiguration(List toolCommand, + File workingDirectory) { + List command = new ArrayList<>(toolCommand); command.add("config"); command.add("ls"); command.add("--json"); - Map registries = new HashMap<>(); try { String output = FrontendUtils.executeCommand(command, builder -> builder.directory(workingDirectory)); - JsonNode config = JacksonUtils.readTree(output); - for (String key : config.propertyNames()) { - if ((key.equals("registry") || key.endsWith(":registry")) - && config.get(key).isString()) { - registries.put(key, config.get(key).asString()); - } - } + return JacksonUtils.readTree(output); } catch (CommandExecutionException | RuntimeException e) { - getLogger().debug("Could not read the npm registry configuration; " - + "assuming the default registry.", e); + getLogger().debug("Could not read the configuration using '{}'", + String.join(" ", command), e); + return JacksonUtils.createObjectNode(); } - return registries; } /** @@ -677,9 +729,9 @@ public FrontendVersion getNpmVersion() throws UnknownVersionException { * the {@code --min-release-age} install flag (see * {@link #MIN_NPM_VERSION_FOR_RELEASE_AGE}). Used when building the * {@code npm install} command for the minimum-package-age check (see - * {@link Options#withMinimumFrontendPackageAgeDays(int)}) to decide between - * {@code --min-release-age} and the {@code --before=} fallback - * supported by older npm versions. + * {@link Options#withMinimumFrontendPackageAgeDays(Integer)}) to decide + * between {@code --min-release-age} and the {@code --before=} + * fallback supported by older npm versions. * * @param npmCommand * the npm command to invoke for {@code --version} diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/Options.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/Options.java index c7665b8f31b..3681452cfce 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/Options.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/Options.java @@ -24,6 +24,7 @@ import java.util.Optional; import java.util.Set; +import org.jspecify.annotations.Nullable; import org.slf4j.LoggerFactory; import tools.jackson.databind.JsonNode; @@ -175,11 +176,12 @@ public class Options implements Serializable { /** * Minimum age, in days, that an npm/pnpm/bun frontend package version must - * have before it is allowed to be installed. Defaults to {@code 1} day as a - * mitigation against malicious packages published to the registry; set to - * {@code 0} to disable. + * have before it is allowed to be installed, or {@code null} when nothing + * has been configured on the Vaadin side. In the latter case the value is + * resolved from the package manager configuration, falling back to + * {@link TaskRunNpmInstall#DEFAULT_MINIMUM_FRONTEND_PACKAGE_AGE_DAYS}. */ - private int minimumFrontendPackageAgeDays = 1; + private @Nullable Integer minimumFrontendPackageAgeDays; private ApplicationConfiguration applicationConfiguration; @@ -1179,21 +1181,34 @@ public Options withCommercialBanner(boolean enableCommercialBanner) { * to avoid pulling in brand-new versions that may have been compromised by * a supply-chain attack but not yet detected and removed from the registry. *

- * For npm this is translated to a {@code --before=} argument; for - * pnpm it becomes {@code --config.minimum-release-age=} (requires - * pnpm ≥ 10.16.0); for bun it becomes - * {@code --minimum-release-age=} (requires bun ≥ 1.3.0). + * For npm this is translated to a {@code --min-release-age=} + * argument, or {@code --before=} for npm older than 11.10.0; for pnpm + * it becomes {@code --config.minimum-release-age=} (requires pnpm + * ≥ 10.16.0); for bun it becomes {@code --minimum-release-age=} + * (requires bun ≥ 1.3.0). Since these are command line arguments, they + * take precedence over anything the package manager reads from its own + * configuration. + *

+ * When set to {@code null}, no such argument is passed if the package + * manager already resolves a minimum release age from its own configuration + * ({@code .npmrc}, {@code pnpm-workspace.yaml} or {@code bunfig.toml}), so + * that a manually run {@code npm install} behaves the same way. Only if + * nothing is configured there, + * {@link TaskRunNpmInstall#DEFAULT_MINIMUM_FRONTEND_PACKAGE_AGE_DAYS} is + * used. * * @param minimumFrontendPackageAgeDays - * minimum allowed age in days, or {@code 0} to disable the check + * minimum allowed age in days, {@code 0} to disable the check, + * or {@code null} to use the package manager configuration * @return this builder * @throws IllegalArgumentException * if {@code minimumFrontendPackageAgeDays} is negative * @since 25.1.6 */ public Options withMinimumFrontendPackageAgeDays( - int minimumFrontendPackageAgeDays) { - if (minimumFrontendPackageAgeDays < 0) { + @Nullable Integer minimumFrontendPackageAgeDays) { + if (minimumFrontendPackageAgeDays != null + && minimumFrontendPackageAgeDays < 0) { throw new IllegalArgumentException( "minimumFrontendPackageAgeDays must be >= 0"); } @@ -1204,12 +1219,15 @@ public Options withMinimumFrontendPackageAgeDays( /** * Gets the minimum age (in days) a frontend package version must have * before npm, pnpm or bun is allowed to install it. {@code 0} means the - * check is disabled. + * check is disabled and {@code null} means that nothing has been configured + * on the Vaadin side, in which case the package manager configuration + * decides. See {@link #withMinimumFrontendPackageAgeDays(Integer)}. * - * @return the minimum allowed age in days + * @return the minimum allowed age in days, or {@code null} if not + * configured * @since 25.1.6 */ - public int getMinimumFrontendPackageAgeDays() { + public @Nullable Integer getMinimumFrontendPackageAgeDays() { return minimumFrontendPackageAgeDays; } diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskRunNpmInstall.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskRunNpmInstall.java index e199718960b..c78a08db2be 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskRunNpmInstall.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskRunNpmInstall.java @@ -37,14 +37,17 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import tools.jackson.databind.JsonNode; import com.vaadin.flow.internal.FileIOUtils; import com.vaadin.flow.internal.FrontendUtils; import com.vaadin.flow.internal.Pair; import com.vaadin.flow.server.Constants; +import com.vaadin.flow.server.InitParameters; import com.vaadin.flow.shared.util.SharedUtil; import static com.vaadin.flow.internal.FrontendUtils.commandToString; @@ -62,6 +65,17 @@ */ public class TaskRunNpmInstall implements FallibleCommand { + /** + * The minimum age, in days, a frontend package version must have before it + * is installed, used when neither Vaadin nor the package manager itself has + * been configured with a value. Mitigates supply-chain attacks where a + * compromised version is briefly available on the registry. + */ + static final int DEFAULT_MINIMUM_FRONTEND_PACKAGE_AGE_DAYS = 1; + + private static final Pattern BUNFIG_MINIMUM_RELEASE_AGE = Pattern + .compile("minimumReleaseAge\\s*=\\s*(\\S+)"); + private static final String MODULES_YAML = ".modules.yaml"; private static final String NPM_VALIDATION_FAIL_MESSAGE = "%n%n======================================================================================================" @@ -292,12 +306,8 @@ private void runNpmInstall() throws ExecutionFailedException { } } - boolean npmSupportsMinReleaseAge = options - .getMinimumFrontendPackageAgeDays() > 0 - && !options.isEnableBun() && !options.isEnablePnpm() - && tools.npmSupportsMinReleaseAge(npmExecutable); - getMinimumFrontendPackageAgeArgument(options, npmSupportsMinReleaseAge) - .ifPresent(npmInstallCommand::add); + resolveMinimumFrontendPackageAgeArgument(options, tools, npmExecutable, + logger).ifPresent(npmInstallCommand::add); postinstallCommand.add("run"); postinstallCommand.add("postinstall"); @@ -436,45 +446,163 @@ static String getToolName(Options options) { } } + /** + * Resolves the install argument that stops the active package manager from + * installing frontend package versions that are too new, or nothing if the + * age should not be restricted from here. + *

+ * A value configured through Vaadin is always used as is, {@code 0} + * disabling the check. When nothing is configured, the package manager is + * asked what it resolves for its own minimum release age setting; if it + * already has one, no argument is passed so that the package manager + * applies its own configuration. Only when neither is configured does + * {@link #DEFAULT_MINIMUM_FRONTEND_PACKAGE_AGE_DAYS} apply. + * + * @param options + * current build options + * @param tools + * the frontend tools used to read the package manager + * configuration + * @param toolCommand + * the npm, pnpm or bun command used for the install + * @param logger + * the logger to report the resolved source of the value to + * @return the install argument, or an empty optional if none should be + * passed + */ + static Optional resolveMinimumFrontendPackageAgeArgument( + Options options, FrontendTools tools, List toolCommand, + Logger logger) { + Integer configuredDays = options.getMinimumFrontendPackageAgeDays(); + if (configuredDays != null && configuredDays == 0) { + return Optional.empty(); + } + boolean npmSupportsMinReleaseAge = !options.isEnableBun() + && !options.isEnablePnpm() + && tools.npmSupportsMinReleaseAge(toolCommand); + int days; + if (configuredDays != null) { + days = configuredDays; + } else { + Optional packageManagerValue = getPackageManagerMinimumReleaseAge( + options, tools, toolCommand, npmSupportsMinReleaseAge); + if (packageManagerValue.isPresent()) { + logger.info( + "Keeping the minimum frontend package age configured for {} " + + "({}) instead of applying the Vaadin default. Set the " + + "'{}' parameter to override it.", + getToolName(options), packageManagerValue.get(), + InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS); + return Optional.empty(); + } + days = DEFAULT_MINIMUM_FRONTEND_PACKAGE_AGE_DAYS; + } + return Optional.of(getMinimumFrontendPackageAgeArgument(options, days, + npmSupportsMinReleaseAge)); + } + + /** + * Reads the minimum release age the active package manager resolves from + * its own configuration, so that Vaadin does not override it with a command + * line argument. + */ + private static Optional getPackageManagerMinimumReleaseAge( + Options options, FrontendTools tools, List toolCommand, + boolean npmSupportsMinReleaseAge) { + File npmFolder = options.getNpmFolder(); + if (options.isEnableBun()) { + // bun has no command for printing its resolved configuration + return getBunfigMinimumReleaseAge(npmFolder); + } + if (options.isEnablePnpm()) { + // pnpm reads this from .npmrc and pnpm-workspace.yaml alike + return tools.getConfiguredSetting(toolCommand, + "minimum-release-age", npmFolder); + } + if (npmSupportsMinReleaseAge) { + return tools.getConfiguredSetting(toolCommand, "min-release-age", + npmFolder); + } + // Older npm has no min-release-age setting, but the --before argument + // used as a fallback does have a configuration counterpart + return tools.getConfiguredSetting(toolCommand, "before", npmFolder); + } + + /** + * Reads the {@code minimumReleaseAge} setting from the project and user + * {@code bunfig.toml} files. The setting is only valid in the + * {@code [install]} section, which is the only section it is looked for in. + */ + private static Optional getBunfigMinimumReleaseAge(File npmFolder) { + return Stream + .of(new File(npmFolder, "bunfig.toml"), + new File(FileIOUtils.getUserDirectory(), + ".bunfig.toml")) + .filter(File::canRead) + .map(TaskRunNpmInstall::getBunfigMinimumReleaseAgeFromFile) + .flatMap(Optional::stream).findFirst(); + } + + private static Optional getBunfigMinimumReleaseAgeFromFile( + File bunfig) { + try { + boolean inInstallSection = false; + for (String line : Files.readAllLines(bunfig.toPath(), + StandardCharsets.UTF_8)) { + String trimmed = line.trim(); + if (trimmed.startsWith("[")) { + inInstallSection = "[install]".equals(trimmed); + } else if (inInstallSection) { + Matcher matcher = BUNFIG_MINIMUM_RELEASE_AGE + .matcher(trimmed); + if (matcher.lookingAt()) { + return Optional.of(matcher.group(1)); + } + } + } + } catch (IOException e) { + LoggerFactory.getLogger(TaskRunNpmInstall.class).debug( + "Could not read the minimumReleaseAge setting from {}", + bunfig, e); + } + return Optional.empty(); + } + /** * Builds the install argument that prevents npm, pnpm or bun from - * installing frontend package versions newer than - * {@link Options#getMinimumFrontendPackageAgeDays()} days. Returns an empty - * optional when the check is disabled. + * installing frontend package versions newer than the given number of days. * * @param options * current build options + * @param days + * the minimum age in days, always positive * @param npmSupportsMinReleaseAge * {@code true} if the active npm understands * {@code --min-release-age} (npm 11.10+); when {@code false} the * legacy {@code --before=} flag is used instead. Ignored * when bun or pnpm is enabled. */ - static Optional getMinimumFrontendPackageAgeArgument( - Options options, boolean npmSupportsMinReleaseAge) { - int days = options.getMinimumFrontendPackageAgeDays(); - if (days <= 0) { - return Optional.empty(); - } + static String getMinimumFrontendPackageAgeArgument(Options options, + int days, boolean npmSupportsMinReleaseAge) { if (options.isEnableBun()) { // bun: --minimum-release-age takes a value in seconds long seconds = (long) days * 24 * 60 * 60; - return Optional.of("--minimum-release-age=" + seconds); + return "--minimum-release-age=" + seconds; } if (options.isEnablePnpm()) { // pnpm: minimumReleaseAge is a setting (in minutes), so it has // to be passed via the --config. CLI form, not as a // top-level option long minutes = (long) days * 24 * 60; - return Optional.of("--config.minimum-release-age=" + minutes); + return "--config.minimum-release-age=" + minutes; } if (npmSupportsMinReleaseAge) { // npm 11.10+: --min-release-age takes a value in days - return Optional.of("--min-release-age=" + days); + return "--min-release-age=" + days; } // Older npm: --before takes any Date.parse-able string String before = Instant.now().minus(days, ChronoUnit.DAYS).toString(); - return Optional.of("--before=" + before); + return "--before=" + before; } private void consumeProcessOutput(Process process, diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunNpmInstallTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunNpmInstallTest.java index bc808061a7b..92e1ceabd7b 100644 --- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunNpmInstallTest.java +++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunNpmInstallTest.java @@ -767,93 +767,113 @@ private void assumeNPMIsInUse() { } @Test - void minimumFrontendPackageAge_defaultIsOneDay_addsArgument() { - // Default is 1 day → 1440 minutes for pnpm, 86400 seconds for bun, - // --min-release-age=1 for npm 11.10+, and --before= for - // older npm - assertEquals("--min-release-age=1", - TaskRunNpmInstall - .getMinimumFrontendPackageAgeArgument( - new MockOptions(npmFolder), true) - .orElseThrow()); - assertTrue(TaskRunNpmInstall - .getMinimumFrontendPackageAgeArgument( - new MockOptions(npmFolder), false) - .orElseThrow().startsWith("--before=")); - assertEquals("--config.minimum-release-age=1440", - TaskRunNpmInstall.getMinimumFrontendPackageAgeArgument( - new MockOptions(npmFolder).withEnablePnpm(true), false) - .orElseThrow()); - assertEquals("--minimum-release-age=86400", - TaskRunNpmInstall.getMinimumFrontendPackageAgeArgument( - new MockOptions(npmFolder).withEnableBun(true), false) - .orElseThrow()); - } - - @Test - void minimumFrontendPackageAge_zeroDisablesCheck_returnsEmpty() { - Options npmOptions = new MockOptions(npmFolder) - .withMinimumFrontendPackageAgeDays(0); - assertFalse(TaskRunNpmInstall - .getMinimumFrontendPackageAgeArgument(npmOptions, true) - .isPresent()); - assertFalse(TaskRunNpmInstall - .getMinimumFrontendPackageAgeArgument(npmOptions, false) - .isPresent()); - assertFalse(TaskRunNpmInstall - .getMinimumFrontendPackageAgeArgument( - new MockOptions(npmFolder).withEnablePnpm(true) - .withMinimumFrontendPackageAgeDays(0), - false) - .isPresent()); - assertFalse(TaskRunNpmInstall - .getMinimumFrontendPackageAgeArgument( - new MockOptions(npmFolder).withEnableBun(true) - .withMinimumFrontendPackageAgeDays(0), - false) - .isPresent()); - } - - @Test - void minimumFrontendPackageAge_npmNewEnough_addsMinReleaseAgeArgument() { - Options npmOptions = new MockOptions(npmFolder) - .withMinimumFrontendPackageAgeDays(2); - Optional arg = TaskRunNpmInstall - .getMinimumFrontendPackageAgeArgument(npmOptions, true); + void minimumFrontendPackageAge_npmNewEnough_usesMinReleaseAgeArgument() { // npm 11.10+: --min-release-age takes a value in days - assertEquals("--min-release-age=2", arg.orElseThrow()); + assertEquals("--min-release-age=2", + TaskRunNpmInstall.getMinimumFrontendPackageAgeArgument( + new MockOptions(npmFolder), 2, true)); } @Test void minimumFrontendPackageAge_npmTooOld_fallsBackToBeforeArgument() { - Options npmOptions = new MockOptions(npmFolder) - .withMinimumFrontendPackageAgeDays(2); - Optional arg = TaskRunNpmInstall - .getMinimumFrontendPackageAgeArgument(npmOptions, false); - assertTrue(arg.isPresent()); - assertTrue(arg.get().startsWith("--before="), - "Older npm should fall back to --before, was: " + arg.get()); + String arg = TaskRunNpmInstall.getMinimumFrontendPackageAgeArgument( + new MockOptions(npmFolder), 2, false); + assertTrue(arg.startsWith("--before="), + "Older npm should fall back to --before, was: " + arg); } @Test - void minimumFrontendPackageAge_pnpm_addsMinimumReleaseAgeArgument() { - Options pnpmOptions = new MockOptions(npmFolder).withEnablePnpm(true) - .withMinimumFrontendPackageAgeDays(2); - Optional arg = TaskRunNpmInstall - .getMinimumFrontendPackageAgeArgument(pnpmOptions, false); + void minimumFrontendPackageAge_pnpm_usesMinimumReleaseAgeInMinutes() { // 2 days = 2880 minutes; pnpm setting form - assertEquals("--config.minimum-release-age=2880", arg.orElseThrow()); + assertEquals("--config.minimum-release-age=2880", + TaskRunNpmInstall.getMinimumFrontendPackageAgeArgument( + new MockOptions(npmFolder).withEnablePnpm(true), 2, + false)); } @Test - void minimumFrontendPackageAge_bun_addsMinimumReleaseAgeInSeconds() { - Options bunOptions = new MockOptions(npmFolder).withEnableBun(true) - .withMinimumFrontendPackageAgeDays(2); + void minimumFrontendPackageAge_bun_usesMinimumReleaseAgeInSeconds() { // 2 days = 172800 seconds assertEquals("--minimum-release-age=172800", - TaskRunNpmInstall - .getMinimumFrontendPackageAgeArgument(bunOptions, false) - .orElseThrow()); + TaskRunNpmInstall.getMinimumFrontendPackageAgeArgument( + new MockOptions(npmFolder).withEnableBun(true), 2, + false)); + } + + @Test + void resolveMinimumFrontendPackageAge_nothingConfigured_usesDefault() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + + assertEquals("--min-release-age=" + + TaskRunNpmInstall.DEFAULT_MINIMUM_FRONTEND_PACKAGE_AGE_DAYS, + resolveMinimumFrontendPackageAgeArgument( + new MockOptions(npmFolder), tools).orElseThrow()); + } + + @Test + void resolveMinimumFrontendPackageAge_zeroConfigured_noArgument() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + + assertFalse(resolveMinimumFrontendPackageAgeArgument( + new MockOptions(npmFolder).withMinimumFrontendPackageAgeDays(0), + tools).isPresent()); + } + + @Test + void resolveMinimumFrontendPackageAge_npmrcValue_doesNotOverrideIt() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + Mockito.when(tools.getConfiguredSetting(Mockito.anyList(), + Mockito.eq("min-release-age"), Mockito.eq(npmFolder))) + .thenReturn(Optional.of("7")); + + // No argument is passed, so npm applies its own configuration + assertFalse(resolveMinimumFrontendPackageAgeArgument( + new MockOptions(npmFolder), tools).isPresent()); + } + + @Test + void resolveMinimumFrontendPackageAge_configuredInVaadin_overridesNpmrcValue() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + Mockito.when(tools.getConfiguredSetting(Mockito.anyList(), + Mockito.eq("min-release-age"), Mockito.eq(npmFolder))) + .thenReturn(Optional.of("7")); + + assertEquals("--min-release-age=3", + resolveMinimumFrontendPackageAgeArgument( + new MockOptions(npmFolder) + .withMinimumFrontendPackageAgeDays(3), + tools).orElseThrow()); + } + + @Test + void resolveMinimumFrontendPackageAge_bunfigValue_doesNotOverrideIt() + throws IOException { + Files.writeString(new File(npmFolder, "bunfig.toml").toPath(), """ + [install] + # minimumReleaseAge = 1 + minimumReleaseAge = 604800 + """); + + assertFalse(resolveMinimumFrontendPackageAgeArgument( + new MockOptions(npmFolder).withEnableBun(true), + mockToolsWithoutMinimumReleaseAge()).isPresent()); + } + + private FrontendTools mockToolsWithoutMinimumReleaseAge() { + FrontendTools tools = Mockito.mock(FrontendTools.class); + Mockito.when(tools.npmSupportsMinReleaseAge(Mockito.anyList())) + .thenReturn(true); + Mockito.when(tools.getConfiguredSetting(Mockito.anyList(), + Mockito.anyString(), Mockito.any())) + .thenReturn(Optional.empty()); + return tools; + } + + private Optional resolveMinimumFrontendPackageAgeArgument( + Options options, FrontendTools tools) { + return TaskRunNpmInstall.resolveMinimumFrontendPackageAgeArgument( + options, tools, List.of("npm"), + LoggerFactory.getLogger(TaskRunNpmInstallTest.class)); } } diff --git a/flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildDevBundleMojo.java b/flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildDevBundleMojo.java index 6ad34846969..0b869c41113 100644 --- a/flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildDevBundleMojo.java +++ b/flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildDevBundleMojo.java @@ -214,13 +214,18 @@ public class BuildDevBundleMojo extends AbstractMojo /** * Minimum age (in days) a frontend (npm) package version must have before * npm, pnpm or bun is allowed to install it. Mitigates supply-chain attacks - * where a compromised version is briefly available on the registry. - * Defaults to {@code 1} day; set to {@code 0} to disable. Requires pnpm - * ≥ 10.16.0 or bun ≥ 1.3.0 when those tools are used. + * where a compromised version is briefly available on the registry. Set to + * {@code 0} to disable. Requires pnpm ≥ 10.16.0 or bun ≥ 1.3.0 when + * those tools are used. + *

+ * When not set, the value configured for the package manager itself + * ({@code .npmrc}, {@code pnpm-workspace.yaml} or {@code bunfig.toml}) is + * used, so that a manually run {@code npm install} behaves the same way. + * Only when there is no such value does the check default to {@code 1} day. */ @Parameter(property = "vaadin." - + InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS, defaultValue = "1") - private int minimumFrontendPackageAgeDays; + + InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS) + private Integer minimumFrontendPackageAgeDays; /** * The folder where the META-INF/resources files are copied. Used for @@ -591,7 +596,7 @@ public boolean isFrontendIgnoreVersionChecks() { } @Override - public int minimumFrontendPackageAgeDays() { + public Integer minimumFrontendPackageAgeDays() { return minimumFrontendPackageAgeDays; } diff --git a/flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/GradlePluginAdapter.kt b/flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/GradlePluginAdapter.kt index ed674b1b02f..3499e89ed94 100644 --- a/flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/GradlePluginAdapter.kt +++ b/flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/GradlePluginAdapter.kt @@ -373,7 +373,9 @@ internal class GradlePluginAdapter private constructor( return config.commercialWithBanner.get() } - override fun minimumFrontendPackageAgeDays(): Int = - config.minimumFrontendPackageAgeDays.get() + // Null when not configured, so that the value configured for the package + // manager itself is used instead of being overridden + override fun minimumFrontendPackageAgeDays(): Int? = + config.minimumFrontendPackageAgeDays.orNull } diff --git a/flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/VaadinFlowPluginExtension.kt b/flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/VaadinFlowPluginExtension.kt index 142c0ba0aae..416a6164a33 100644 --- a/flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/VaadinFlowPluginExtension.kt +++ b/flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/VaadinFlowPluginExtension.kt @@ -360,8 +360,14 @@ public abstract class VaadinFlowPluginExtension @Inject constructor(private val * Minimum age (in days) a frontend (npm) package version must have before * npm, pnpm or bun is allowed to install it. Mitigates supply-chain * attacks where a compromised version is briefly available on the - * registry. Defaults to {@code 1} day; set to {@code 0} to disable. - * Requires pnpm >= 10.16.0 or bun >= 1.3.0 when those tools are used. + * registry. Set to {@code 0} to disable. Requires pnpm >= 10.16.0 or bun + * >= 1.3.0 when those tools are used. + * + * When not set, the value configured for the package manager itself + * ({@code .npmrc}, {@code pnpm-workspace.yaml} or {@code bunfig.toml}) is + * used, so that a manually run {@code npm install} behaves the same way. + * Only when there is no such value does the check default to {@code 1} + * day. */ public abstract val minimumFrontendPackageAgeDays: Property @@ -677,7 +683,7 @@ public class PluginEffectiveConfiguration( project.getStringProperty( "vaadin.${InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS}" ).map(String::toInt) - .orElse(extension.minimumFrontendPackageAgeDays.convention(1)) + .orElse(extension.minimumFrontendPackageAgeDays) public val npmExcludeWebComponents: Provider = extension .npmExcludeWebComponents.convention(false) diff --git a/flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildFrontendMojo.java b/flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildFrontendMojo.java index 2e1b2fdf106..a3453695199 100644 --- a/flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildFrontendMojo.java +++ b/flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildFrontendMojo.java @@ -161,13 +161,18 @@ public class BuildFrontendMojo extends FlowModeAbstractMojo /** * Minimum age (in days) a frontend (npm) package version must have before * npm, pnpm or bun is allowed to install it. Mitigates supply-chain attacks - * where a compromised version is briefly available on the registry. - * Defaults to {@code 1} day; set to {@code 0} to disable. Requires pnpm - * ≥ 10.16.0 or bun ≥ 1.3.0 when those tools are used. + * where a compromised version is briefly available on the registry. Set to + * {@code 0} to disable. Requires pnpm ≥ 10.16.0 or bun ≥ 1.3.0 when + * those tools are used. + *

+ * When not set, the value configured for the package manager itself + * ({@code .npmrc}, {@code pnpm-workspace.yaml} or {@code bunfig.toml}) is + * used, so that a manually run {@code npm install} behaves the same way. + * Only when there is no such value does the check default to {@code 1} day. */ @Parameter(property = "vaadin." - + InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS, defaultValue = "1") - private int minimumFrontendPackageAgeDays; + + InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS) + private Integer minimumFrontendPackageAgeDays; @Override protected void executeInternal() @@ -328,7 +333,7 @@ public File resourcesOutputDirectory() { } @Override - public int minimumFrontendPackageAgeDays() { + public Integer minimumFrontendPackageAgeDays() { return minimumFrontendPackageAgeDays; } diff --git a/flow-plugins/flow-plugin-base/src/main/java/com/vaadin/flow/plugin/base/PluginAdapterBuild.java b/flow-plugins/flow-plugin-base/src/main/java/com/vaadin/flow/plugin/base/PluginAdapterBuild.java index 0767df82915..2bf3647c97b 100644 --- a/flow-plugins/flow-plugin-base/src/main/java/com/vaadin/flow/plugin/base/PluginAdapterBuild.java +++ b/flow-plugins/flow-plugin-base/src/main/java/com/vaadin/flow/plugin/base/PluginAdapterBuild.java @@ -129,13 +129,17 @@ boolean checkRuntimeDependency(String groupId, String artifactId, /** * Minimum age (in days) a frontend package version must have before npm, - * pnpm or bun is allowed to install it. Defaults to {@code 1} day as a - * mitigation against malicious packages briefly published to the registry; - * set to {@code 0} to disable. + * pnpm or bun is allowed to install it, as a mitigation against malicious + * packages briefly published to the registry. {@code 0} disables the check. + *

+ * When {@code null}, the value configured for the package manager itself + * ({@code .npmrc}, {@code pnpm-workspace.yaml} or {@code bunfig.toml}) is + * used, defaulting to one day if there is none. * - * @return the minimum allowed age in days, or {@code 0} when disabled + * @return the minimum allowed age in days, {@code 0} when disabled, or + * {@code null} when not configured */ - default int minimumFrontendPackageAgeDays() { - return 1; + default Integer minimumFrontendPackageAgeDays() { + return null; } } diff --git a/flow-server/src/main/java/com/vaadin/flow/server/InitParameters.java b/flow-server/src/main/java/com/vaadin/flow/server/InitParameters.java index 8f1c5fe2395..8db53565299 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/InitParameters.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/InitParameters.java @@ -409,9 +409,13 @@ public class InitParameters implements Serializable { /** * Configuration name for the minimum age (in days) a frontend (npm) package - * version must have before npm, pnpm or bun is allowed to install it. - * Defaults to {@code 1} day; set to {@code 0} to disable. - * + * version must have before npm, pnpm or bun is allowed to install it. Set + * to {@code 0} to disable. + *

+ * When not set, the value configured for the package manager itself + * ({@code .npmrc}, {@code pnpm-workspace.yaml} or {@code bunfig.toml}) is + * used, defaulting to {@code 1} day if there is none. + * * @since 25.1.6 */ public static final String MINIMUM_FRONTEND_PACKAGE_AGE_DAYS = "npm.minimumFrontendPackageAgeDays"; diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/startup/DevModeInitializer.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/startup/DevModeInitializer.java index b0c73e2f0ab..192b0683dad 100644 --- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/startup/DevModeInitializer.java +++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/startup/DevModeInitializer.java @@ -293,8 +293,13 @@ public static DevModeHandler initDevModeHandler(Set> classes, boolean npmExcludeWebComponents = config .getBooleanProperty(NPM_EXCLUDE_WEB_COMPONENTS, false); - int minimumFrontendPackageAgeDays = Integer.parseInt(config - .getStringProperty(MINIMUM_FRONTEND_PACKAGE_AGE_DAYS, "1")); + // Left as null when not configured, so that the value configured for + // the package manager itself is used instead of being overridden + String minimumFrontendPackageAge = config + .getStringProperty(MINIMUM_FRONTEND_PACKAGE_AGE_DAYS, null); + Integer minimumFrontendPackageAgeDays = minimumFrontendPackageAge == null + ? null + : Integer.valueOf(minimumFrontendPackageAge); options.enablePackagesUpdate(true) .useByteCodeScanner(useByteCodeScanner) diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/startup/DevModeInitializerTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/startup/DevModeInitializerTest.java index 314c09ec570..87d1808ea0b 100644 --- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/startup/DevModeInitializerTest.java +++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/startup/DevModeInitializerTest.java @@ -260,13 +260,22 @@ void should_Run_Updaters_when_NoNodeConfFiles() throws Exception { void minimumFrontendPackageAgeDays_readFromConfig_passedToOptions() throws Exception { Mockito.when(appConfig.getStringProperty( - InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS, "1")) + InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS, null)) .thenReturn("7"); assertEquals(7, captureNodeTasksOptions().getMinimumFrontendPackageAgeDays()); } + @Test + void minimumFrontendPackageAgeDays_notInConfig_leftUnsetInOptions() + throws Exception { + // Null lets the package manager configuration decide instead of + // overriding it with a command line argument + assertNull( + captureNodeTasksOptions().getMinimumFrontendPackageAgeDays()); + } + private Options captureNodeTasksOptions() throws Exception { List captured = new ArrayList<>(); try (MockedConstruction ignored = Mockito