// The snapshot's input is the COMMITTED binding default in config/security.yml, not src/.env. // // It used to read rootProject.file('.env'). /.gitignore:7 excludes `src/.env*` (allowing only the // two *.example files), so `git ls-files src/.env` is empty and the file does not exist in a CI // checkout — a gate whose expected value comes from an untracked file is not reproducible, and the // first line of this closure turned that into a hard failure on any clean machine. Locally it was // worse than a failure: it passed against one developer's file. The committed snapshot recorded // `/api/healthcheck`, taken from that local .env, while the shipped default in // app-bootstrap/src/main/resources/config/security.yml binds // public-paths: ${SECURITY_PUBLIC_PATHS:${PRESENTATION_API_BASE_PATH:/v1}/healthcheck} // = /v1/healthcheck. The reviewed snapshot therefore described a surface no deployment had. // // What the snapshot now pins is the permitAll surface a deployment gets when no operator override // is set — the thing a reviewer must see change. An operator's own SECURITY_PUBLIC_PATHS at run // time is outside the repository and outside any build gate; the default is the part this // repository is accountable for. Closure renderPublicPathSnapshot = { File securityConfigFile -> if (!securityConfigFile.isFile()) { throw new GradleException( "missing public-path security configuration ${securityConfigFile}") } def bindingPattern = ~/^\s*public-paths:\s*(\S.*?)\s*$/ List bindings = securityConfigFile.readLines('UTF-8').findResults { String line -> def matcher = bindingPattern.matcher(line) matcher.matches() ? matcher.group(1) : null } if (bindings.size() != 1) { throw new GradleException( "expected exactly one 'public-paths:' binding in ${securityConfigFile}, " + "found ${bindings.size()} — the snapshot cannot say which surface it pins") } // Resolve Spring placeholders to their defaults, innermost first: // ${A:${B:/v1}/healthcheck} -> ${A:/v1/healthcheck} -> /v1/healthcheck. // `[^{}]*` only ever matches the innermost placeholder, so one substitution per pass unwinds // the nesting from the inside out without any replacement-string escaping. def defaultedPlaceholder = ~/\$\{[A-Za-z0-9_.]+:([^{}]*)\}/ String raw = bindings.first() for (int guard = 0; guard < 16; guard++) { def matcher = defaultedPlaceholder.matcher(raw) if (!matcher.find()) { break } raw = raw.substring(0, matcher.start()) + matcher.group(1) + raw.substring(matcher.end()) } if (raw.contains('${')) { throw new GradleException( "'public-paths' in ${securityConfigFile} resolves to '${raw}', which still holds a " + 'placeholder with no default — the deployed public path surface is not ' + 'determined by the repository and cannot be snapshotted') } List publicPaths = raw.split(',') .collect { String value -> value.trim() } .findAll { String value -> !value.isEmpty() } .toSorted() String header = "# feature-security-operational-baseline D5 — deny-by-default public path snapshot.\n" + "# SSOT: ca-skeleton.security.public-paths default in " + "app-bootstrap/src/main/resources/config/security.yml\n" + "# -> SecurityConfig permitAll(); anyRequest authenticated. An operator's own " + "SECURITY_PUBLIC_PATHS\n" + "# overrides it at run time and is outside this snapshot.\n" + "# Update only after review with: ./gradlew updatePublicPathSnapshot " + "-PapprovePublicPathChange\n" header + (publicPaths.isEmpty() ? '' : publicPaths.join('\n') + '\n') } File publicPathSourceFile = rootProject.file('app-bootstrap/src/main/resources/config/security.yml') File publicPathSnapshotFile = rootProject.file('../docs/security/public-paths-snapshot.txt') boolean publicPathUpdateApproved = project.hasProperty('approvePublicPathChange') def existingPublicPathSource = providers.provider { publicPathSourceFile.isFile() ? publicPathSourceFile : null } def existingPublicPathSnapshot = providers.provider { publicPathSnapshotFile.isFile() ? publicPathSnapshotFile : null } tasks.register('verifyPublicPathSnapshot') { group = 'verification' description = 'Fails without mutation when the committed deny-by-default public path baseline drifts.' inputs.file(existingPublicPathSource).optional() inputs.file(existingPublicPathSnapshot).optional() inputs.property('updateApprovalRequested', publicPathUpdateApproved) doLast { if (publicPathUpdateApproved) { throw new GradleException( 'verifyPublicPathSnapshot is read-only; use updatePublicPathSnapshot ' + '-PapprovePublicPathChange for an intentional update.') } String canonical try { canonical = renderPublicPathSnapshot(publicPathSourceFile) } catch (GradleException exception) { throw new GradleException( "verifyPublicPathSnapshot: ${exception.message}", exception) } if (!publicPathSnapshotFile.isFile()) { throw new GradleException( "verifyPublicPathSnapshot: missing committed baseline ${publicPathSnapshotFile}") } String existing = publicPathSnapshotFile.getText('UTF-8') if (existing != canonical) { throw new GradleException( "verifyPublicPathSnapshot: the deny-by-default public path surface changed.\n" + " expected (snapshot):\n${existing}\n" + " actual (security.yml public-paths default):\n${canonical}\n" + 'A protected endpoint may now be public. Review the change, then run:\n' + ' ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange') } logger.lifecycle( 'verifyPublicPathSnapshot: OK — committed public paths are unchanged.') } } tasks.register('updatePublicPathSnapshot') { group = 'build setup' description = 'Explicitly updates the committed public path baseline after security review.' inputs.file(existingPublicPathSource).optional() inputs.property('approved', publicPathUpdateApproved) outputs.file(publicPathSnapshotFile) outputs.upToDateWhen { false } doLast { if (!publicPathUpdateApproved) { throw new GradleException( 'updatePublicPathSnapshot requires -PapprovePublicPathChange') } String canonical try { canonical = renderPublicPathSnapshot(publicPathSourceFile) } catch (GradleException exception) { throw new GradleException( "updatePublicPathSnapshot: ${exception.message}", exception) } if (!publicPathSnapshotFile.parentFile.isDirectory() && !publicPathSnapshotFile.parentFile.mkdirs()) { throw new GradleException( "updatePublicPathSnapshot: failed to create ${publicPathSnapshotFile.parentFile}") } publicPathSnapshotFile.setText(canonical, 'UTF-8') logger.lifecycle( "updatePublicPathSnapshot: wrote reviewed baseline ${publicPathSnapshotFile}") } }