-
-
Notifications
You must be signed in to change notification settings - Fork 11
Fix internal map paths in coop mission .scmap files on deploy #328
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| name: Coop deployment scripts | ||
|
|
||
| on: | ||
| push: | ||
| paths: | ||
| - 'apps/faf-legacy-deployment/scripts/**' | ||
| - '.github/workflows/coop-deployment-scripts.yml' | ||
| pull_request: | ||
| paths: | ||
| - 'apps/faf-legacy-deployment/scripts/**' | ||
| - '.github/workflows/coop-deployment-scripts.yml' | ||
|
|
||
| jobs: | ||
| verify: | ||
|
|
||
| runs-on: ubuntu-latest | ||
| container: | ||
| # same image the deployment CronJobs use | ||
| image: gradle:9.4-jdk21 | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v6 | ||
|
|
||
| - name: Check out the coop missions | ||
| uses: actions/checkout@v6 | ||
| with: | ||
| repository: FAForever/faf-coop-maps | ||
| path: faf-coop-maps | ||
|
|
||
| - name: Compile | ||
| working-directory: apps/faf-legacy-deployment/scripts | ||
| run: gradle --no-daemon compileKotlin | ||
|
|
||
| - name: Round trip the path fixer over every mission map | ||
| working-directory: apps/faf-legacy-deployment/scripts | ||
| env: | ||
| MAPS_REPO: ${{ github.workspace }}/faf-coop-maps | ||
| run: gradle --no-daemon verifyScmapFixer | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,13 +7,17 @@ import com.faforever.FafDatabase | |||||||||||||||||||||||||||||||||||||||||||||
| import com.faforever.GitRepo | ||||||||||||||||||||||||||||||||||||||||||||||
| import com.faforever.Log | ||||||||||||||||||||||||||||||||||||||||||||||
| import com.faforever.extractChecksumsFromZip | ||||||||||||||||||||||||||||||||||||||||||||||
| import com.faforever.fixScmapPaths | ||||||||||||||||||||||||||||||||||||||||||||||
| import com.faforever.generateChecksums | ||||||||||||||||||||||||||||||||||||||||||||||
| import com.faforever.isScmap | ||||||||||||||||||||||||||||||||||||||||||||||
| import com.faforever.referencesOwnMapFolder | ||||||||||||||||||||||||||||||||||||||||||||||
| import org.apache.commons.compress.archivers.zip.ZipArchiveEntry | ||||||||||||||||||||||||||||||||||||||||||||||
| import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream | ||||||||||||||||||||||||||||||||||||||||||||||
| import org.slf4j.LoggerFactory | ||||||||||||||||||||||||||||||||||||||||||||||
| import java.nio.file.Files | ||||||||||||||||||||||||||||||||||||||||||||||
| import java.nio.file.Path | ||||||||||||||||||||||||||||||||||||||||||||||
| import java.nio.file.Paths | ||||||||||||||||||||||||||||||||||||||||||||||
| import java.nio.file.StandardCopyOption | ||||||||||||||||||||||||||||||||||||||||||||||
| import kotlin.io.path.copyTo | ||||||||||||||||||||||||||||||||||||||||||||||
| import kotlin.io.path.createDirectories | ||||||||||||||||||||||||||||||||||||||||||||||
| import kotlin.io.path.isDirectory | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -151,9 +155,17 @@ private fun processCoopMap( | |||||||||||||||||||||||||||||||||||||||||||||
| val newVersion = currentVersion + 1 | ||||||||||||||||||||||||||||||||||||||||||||||
| log.info("$map updated → v$newVersion") | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| verifyRelease(map, newVersion, files, tmp) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if (!simulate) { | ||||||||||||||||||||||||||||||||||||||||||||||
| val finalZip = Path.of(mapsDir, map.zipName(newVersion)) | ||||||||||||||||||||||||||||||||||||||||||||||
| createZip(map, newVersion, files, tmp, finalZip) | ||||||||||||||||||||||||||||||||||||||||||||||
| val partialZip = Path.of(mapsDir, "${map.zipName(newVersion)}.part") | ||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||
| createZip(map, newVersion, files, tmp, partialZip) | ||||||||||||||||||||||||||||||||||||||||||||||
| Files.move(partialZip, finalZip, StandardCopyOption.REPLACE_EXISTING) | ||||||||||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||||||||||
| Files.deleteIfExists(partialZip) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| db.update(map, newVersion) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -180,6 +192,17 @@ private fun generateChecksumsForMap( | |||||||||||||||||||||||||||||||||||||||||||||
| * Get file content with path rewriting for text files. | ||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||
| private fun getFileContent(file: Path, map: CoopMap, version: Int): ByteArray { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (file.isScmapFile()) { | ||||||||||||||||||||||||||||||||||||||||||||||
| val bytes = file.readBytes() | ||||||||||||||||||||||||||||||||||||||||||||||
| // Only missions whose map references assets in their own folder need the version | ||||||||||||||||||||||||||||||||||||||||||||||
| // inserted. Everything else - including the placeholder .scmap files of the missions | ||||||||||||||||||||||||||||||||||||||||||||||
| // that use a base game map - is passed through and never parsed. | ||||||||||||||||||||||||||||||||||||||||||||||
| return if (bytes.referencesOwnMapFolder(map.folderName)) { | ||||||||||||||||||||||||||||||||||||||||||||||
| fixScmapPaths(bytes, map.folderName, version).bytes | ||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||
| bytes | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+195
to
+205
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Missing
🐛 Proposed fix- return if (bytes.referencesOwnMapFolder(map.folderName)) {
+ return if (bytes.isScmap() && bytes.referencesOwnMapFolder(map.folderName)) {
fixScmapPaths(bytes, map.folderName, version).bytes
} else {
bytes
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| return if (file.isTextFile()) { | ||||||||||||||||||||||||||||||||||||||||||||||
| var text = file.readText() | ||||||||||||||||||||||||||||||||||||||||||||||
| .replace( | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -196,6 +219,61 @@ private fun getFileContent(file: Path, map: CoopMap, version: Int): ByteArray { | |||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||
| * Checks that every path the release points at actually exists in the release. | ||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||
| * The paths rewritten inside a .scmap are checked hard: if one of them does not resolve to | ||||||||||||||||||||||||||||||||||||||||||||||
| * a file that ends up in the zip, the mission is not deployed at all. That is the case the | ||||||||||||||||||||||||||||||||||||||||||||||
| * whole path rewriting exists for, and getting it wrong ships a map with missing textures | ||||||||||||||||||||||||||||||||||||||||||||||
| * that nothing else would notice. | ||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||
| * References in text files are only reported. Some of them have been broken for years - | ||||||||||||||||||||||||||||||||||||||||||||||
| * typos in comment headers - and failing on those would block releases for cosmetic reasons. | ||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||
| * @throws IllegalStateException if a rewritten map path does not resolve | ||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||
| private fun verifyRelease(map: CoopMap, version: Int, files: List<Path>, base: Path) { | ||||||||||||||||||||||||||||||||||||||||||||||
| val prefix = "/maps/${map.folderName(version)}/".lowercase() | ||||||||||||||||||||||||||||||||||||||||||||||
| val shipped = files | ||||||||||||||||||||||||||||||||||||||||||||||
| .map { prefix + base.relativize(it).toString().replace("\\", "/").lowercase() } | ||||||||||||||||||||||||||||||||||||||||||||||
| .toSet() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // over capture is possible when a path is read out of binary data, so a reference counts | ||||||||||||||||||||||||||||||||||||||||||||||
| // as resolved when it starts with a shipped file | ||||||||||||||||||||||||||||||||||||||||||||||
| fun resolves(reference: String) = | ||||||||||||||||||||||||||||||||||||||||||||||
| shipped.any { reference.lowercase() == it || reference.lowercase().startsWith(it) } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| val broken = mutableListOf<String>() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| files.forEach { file -> | ||||||||||||||||||||||||||||||||||||||||||||||
| val relative = base.relativize(file).toString().replace("\\", "/") | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if (file.isScmapFile()) { | ||||||||||||||||||||||||||||||||||||||||||||||
| val bytes = file.readBytes() | ||||||||||||||||||||||||||||||||||||||||||||||
| if (bytes.isScmap() && bytes.referencesOwnMapFolder(map.folderName)) { | ||||||||||||||||||||||||||||||||||||||||||||||
| fixScmapPaths(bytes, map.folderName, version).rewritten | ||||||||||||||||||||||||||||||||||||||||||||||
| .filterNot(::resolves) | ||||||||||||||||||||||||||||||||||||||||||||||
| .forEach { broken += "$relative points at $it, which is not in the release" } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } else if (file.isTextFile()) { | ||||||||||||||||||||||||||||||||||||||||||||||
| val text = String(getFileContent(file, map, version), Charsets.ISO_8859_1) | ||||||||||||||||||||||||||||||||||||||||||||||
| MAP_REFERENCE.findAll(text) | ||||||||||||||||||||||||||||||||||||||||||||||
| .map { it.value } | ||||||||||||||||||||||||||||||||||||||||||||||
| .filter { it.lowercase().startsWith("/maps/${map.folderName.lowercase()}") } | ||||||||||||||||||||||||||||||||||||||||||||||
| .filterNot(::resolves) | ||||||||||||||||||||||||||||||||||||||||||||||
| .distinct() | ||||||||||||||||||||||||||||||||||||||||||||||
| .forEach { log.warn("$map: $relative points at $it, which is not in the release") } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| check(broken.isEmpty()) { | ||||||||||||||||||||||||||||||||||||||||||||||
| broken.forEach { log.error("$map: $it") } | ||||||||||||||||||||||||||||||||||||||||||||||
| "$map: ${broken.size} rewritten map path(s) do not resolve, not deploying this mission" | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| private val MAP_REFERENCE = Regex("""/maps/[^"'\s,)]+""", RegexOption.IGNORE_CASE) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| private fun createZip( | ||||||||||||||||||||||||||||||||||||||||||||||
| map: CoopMap, | ||||||||||||||||||||||||||||||||||||||||||||||
| version: Int, | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -238,6 +316,8 @@ private fun createZip( | |||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| private fun Path.isTextFile() = listOf(".md", ".lua", ".json", ".txt").any { toString().endsWith(it) } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| private fun Path.isScmapFile() = toString().lowercase().endsWith(".scmap") | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| fun main(args: Array<String>) { | ||||||||||||||||||||||||||||||||||||||||||||||
| Log.init() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: FAForever/gitops-stack
Length of output: 1860
🌐 Web query:
actions/checkout persist-credentials default true README💡 Result:
The persist-credentials input in the actions/checkout GitHub Action currently has a default value of true [1][2][3]. When set to true (the default), the action configures the provided token or SSH key with the local git configuration, which allows your scripts to execute authenticated git commands (such as git push or git fetch) during the job [1][4][5]. These credentials are automatically removed by the action during post-job cleanup [1][4][5]. Although a pull request has been proposed to change this default to false to enhance security and reduce the risk of unintentional credential exposure, it remains set to true in the current official releases [6][1]. Users who wish to opt out of this behavior and prevent the action from configuring their credentials in the git config can explicitly set persist-credentials to false in their workflow configuration [1][4].
Citations:
🌐 Web query:
GitHub Actions contents: read checkout token persists Git config💡 Result:
The GitHub Actions
actions/checkoutaction persists authentication credentials (such as theGITHUB_TOKENor a provided SSH key/PAT) by default to enable subsequent steps in your workflow to run authenticated Git commands [1][2][3]. Key technical details regarding this behavior include: Credential Storage Mechanism: Historically,actions/checkoutconfigured these credentials directly in the local Git configuration (.git/config) [1][4]. However, beginning with version 6.0.0, the action was updated to store these credentials in a separate, isolated file located under the$RUNNER_TEMPdirectory [5][3][6]. This change uses Git'sincludeIfmechanism to conditionally include the credentials only when necessary, significantly improving security by separating them from the main repository configuration [4]. Persistence Lifecycle: The credentials remain persisted for the duration of the job [1][3]. A post-job cleanup step is responsible for removing these credentials once the job completes [1][2]. Opting Out: If you do not require authenticated Git commands in subsequent steps, you can prevent this behavior by setting thepersist-credentialsinput tofalse[1][2]: - uses: actions/checkout@v4 with: persist-credentials: false Security Recommendations: Because thepersist-credentialsoption defaults totrue, any subsequent steps in the same job can potentially access these credentials [1][7]. To follow the principle of least privilege, it is recommended to: 1. Limit the permissions of yourGITHUB_TOKENto the minimum necessary, typicallycontents: read[8][9]. 2. Setpersist-credentials: falseif your workflow does not require further authenticated Git operations [4][7]. 3. Use fine-grained tokens or service accounts with specific scopes if you need to provide an alternative authentication method [2][10].Citations:
persist-credentialsor change the default tofalseactions/checkout#485🏁 Script executed:
Repository: FAForever/gitops-stack
Length of output: 37004
🌐 Web query:
Eclipse JGit Git credentials provider includeIf runner temp GitHub token💡 Result:
JGit does not currently support git config conditional includes (the
includeIfdirective) [1][2]. Consequently, you cannot use this mechanism to automatically inject configuration or credentials based on the file path or other conditions [3]. When working with GitHub Actions and the ephemeralGITHUB_TOKEN, attempting to useUsernamePasswordCredentialsProviderwith just the token often fails [4]. The following approaches are recommended for authentication in this context: 1. Recommended Approach: UseTransportConfigCallbackModern versions of JGit (5.10+) often prioritizehttp.extraheaderconfiguration, which can interfere withUsernamePasswordCredentialsProvider[5]. To ensure your credentials are used, implement aTransportConfigCallbackto set the authentication explicitly [5]. For GitHub Actions, pass the repository owner as the username and theGITHUB_TOKENas the password [4]: final String repoOwner = System.getenv("GITHUB_REPOSITORY_OWNER"); final String githubToken = System.getenv("GITHUB_TOKEN"); CredentialsProvider credentialsProvider = new UsernamePasswordCredentialsProvider(repoOwner, githubToken); TransportConfigCallback transportConfigCallback = transport -> { if (transport instanceof TransportHttp) { ((TransportHttp) transport).setPreemptiveBasicAuthentication(repoOwner, githubToken); } }; Git.cloneRepository.setURI("https://github.com/" + System.getenv("GITHUB_REPOSITORY") + ".git").setCredentialsProvider(credentialsProvider).setTransportConfigCallback(transportConfigCallback).call; 2. Limitations and Context - GITHUB_TOKEN: This token is automatically created for each workflow job and expires when the job finishes [6]. It is an installation access token, not a standard user PAT [6]. - Conditional Includes: Because JGit does not implementincludeIf[1], any logic relying on conditional configuration files in your.gitconfigwill be ignored. - Configuration Precedence: Be aware that if your environment containshttp.extraheadersettings (often injected in CI environments), they may override theCredentialsProvider[5]. Using theTransportConfigCallbackis the most reliable way to bypass these overrides and force the use of your provided credentials [5].Citations:
Do not persist checkout credentials into the Gradle runtime.
Both
actions/checkout@v6steps leave the job token available for the rest of the job by default, and the Gradle tasks run repository-controlled Kotlin/JGit code that can read repo checkouts. Setpersist-credentials: falseon both checkout steps and limit the workflow token tocontents: read.🧰 Tools
🪛 zizmor (1.26.1)
[warning] 22-22: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
[warning] 24-28: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
🤖 Prompt for AI Agents
Source: Linters/SAST tools