Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,20 @@ jobs:
"APP_ASSET=$asset" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8
"APP_PLATFORM_LABEL=${os}-${arch}" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8

- name: Write app bundle SHA-256 sidecar
shell: bash
run: |
set -euo pipefail
node scripts/write-sha256-sidecar.mjs "$APP_ASSET"

- name: Upload standardized app bundle to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pin GitHub actions to commit SHAs for supply-chain security.

The static analysis tool correctly identifies that softprops/action-gh-release@v2 should be pinned to a specific commit hash rather than a mutable tag reference. Mutable tags can be force-pushed, potentially injecting malicious code into your release pipeline.

🔒 Suggested fix

Replace the tag reference with the commit SHA for v2. For example:

-        uses: softprops/action-gh-release@v2
+        uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191  # v2.0.8

Check the action-gh-release releases page for the current v2 commit hash.

Also applies to: 350-350

🧰 Tools
🪛 zizmor (1.25.2)

[error] 293-293: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[info] 293-293: action functionality is already included by the runner (superfluous-actions): use gh release in a script step

(superfluous-actions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml at line 293, Replace the mutable tag reference
softprops/action-gh-release@v2 with a pinned commit SHA to eliminate
supply-chain risk: find occurrences of softprops/action-gh-release@v2 in the
workflow and update them to softprops/action-gh-release@<commit-sha> using the
exact commit hash for the v2 release (obtainable from the action-gh-release
releases/tags page); ensure both occurrences are updated and commit the change.

Source: Linters/SAST tools

with:
tag_name: ${{ github.ref_name }}
files: ${{ env.APP_ASSET }}
files: |
${{ env.APP_ASSET }}
${{ env.APP_ASSET }}.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand All @@ -290,7 +298,9 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: app-${{ env.APP_PLATFORM_LABEL }}
path: ${{ env.APP_ASSET }}
path: |
${{ env.APP_ASSET }}
${{ env.APP_ASSET }}.sha256

# ── Package Windows portable exe (raw unpacked binary) ──
# Naming: Risuko_{version}_win32_{arch}.portable.exe
Expand All @@ -316,12 +326,21 @@ jobs:
Copy-Item $exe $asset
"PORTABLE_ASSET=$asset" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8

- name: Write Windows portable SHA-256 sidecar
if: runner.os == 'Windows'
shell: bash
run: |
set -euo pipefail
node scripts/write-sha256-sidecar.mjs "$PORTABLE_ASSET"

- name: Upload Windows portable exe to GitHub Release
if: runner.os == 'Windows' && startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: ${{ env.PORTABLE_ASSET }}
files: |
${{ env.PORTABLE_ASSET }}
${{ env.PORTABLE_ASSET }}.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand All @@ -330,7 +349,9 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: app-portable-${{ env.APP_PLATFORM_LABEL }}
path: ${{ env.PORTABLE_ASSET }}
path: |
${{ env.PORTABLE_ASSET }}
${{ env.PORTABLE_ASSET }}.sha256

# ── Build standalone risuko-cli binary ──
- name: Build risuko-cli binary
Expand Down
210 changes: 199 additions & 11 deletions packages/risuko-app/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,50 @@
const fs = require("node:fs");
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const path = require("node:path");
const https = require("node:https");
const crypto = require("node:crypto");
const { execFileSync, spawn } = require("node:child_process");

const PKG_VERSION = require("./package.json").version;
const REPO = "YueMiyuki/risuko";
const SHA256_SIDECAR_REQUIRED_VERSION = "0.4.0";

class DownloadHttpError extends Error {
constructor(statusCode, url) {
super(
`Download failed: HTTP ${statusCode} — ${url}\n` +
`To download manually: https://github.com/${REPO}/releases/tag/v${version}`,
);
this.name = "DownloadHttpError";
this.statusCode = statusCode;
this.url = url;
}
}

// -- CLI arg parsing --

const rawArgs = process.argv.slice(2);
let version = PKG_VERSION;
let noCache = false;
let allowLegacyNoChecksum = false;
const appArgs = [];

for (let i = 0; i < rawArgs.length; i++) {
const arg = rawArgs[i];
if ((arg === "--version" || arg === "-v") && rawArgs[i + 1]) {
version = rawArgs[++i];
version = rawArgs[++i].replace(/^v/, "");
} else if (arg === "--no-cache") {
noCache = true;
} else if (arg === "--allow-legacy-no-checksum") {
allowLegacyNoChecksum = true;
} else if (arg === "--help" || arg === "-h") {
console.log(`Usage: risuko-app [launcher-options] [-- app-args...]

Launcher options:
--version <x.y.z> Use a specific release version (default: ${PKG_VERSION})
--no-cache Re-download even if the binary is already cached
--allow-legacy-no-checksum
Allow unsigned installs when SHA-256 sidecar is missing
for legacy releases (pre-0.4.0 or prereleases)
-h, --help Show this help message

Any arguments after -- are passed through to the Risuko app.
Expand Down Expand Up @@ -139,19 +159,17 @@ function download(url, destPath) {
if (
res.statusCode === 301 ||
res.statusCode === 302 ||
res.statusCode === 307
res.statusCode === 303 ||
res.statusCode === 307 ||
res.statusCode === 308
) {
req.destroy();
return download(res.headers.location, destPath).then(resolve, reject);
const nextUrl = new URL(res.headers.location, url).toString();
return download(nextUrl, destPath).then(resolve, reject);
}
if (res.statusCode !== 200) {
req.destroy();
return reject(
new Error(
`Download failed: HTTP ${res.statusCode} — ${url}\n` +
`To download manually: https://github.com/${REPO}/releases/tag/v${version}`,
),
);
return reject(new DownloadHttpError(res.statusCode, url));
}

const total = Number.parseInt(res.headers["content-length"] || "0", 10);
Expand Down Expand Up @@ -189,6 +207,158 @@ function download(url, destPath) {
});
}

function downloadText(url) {
const tmpDir = fs.mkdtempSync(
path.join(require("node:os").tmpdir(), "risuko-launcher-"),
);
const tmpPath = path.join(tmpDir, "download.txt");
return download(url, tmpPath)
.then(() => fs.readFileSync(tmpPath, "utf8"))
.finally(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
}

function sha256File(filePath) {
const hash = crypto.createHash("sha256");
const input = fs.createReadStream(filePath);
return new Promise((resolve, reject) => {
input.on("data", (chunk) => hash.update(chunk));
input.on("error", reject);
input.on("end", () => resolve(hash.digest("hex")));
});
}

function parseExpectedSha256(text, assetName) {
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const [hash, fileName] = trimmed.split(/\s+/, 2);
if (
/^[a-fA-F0-9]{64}$/.test(hash) &&
(!fileName || fileName === assetName)
) {
return hash.toLowerCase();
}
}
throw new Error(`No SHA-256 digest found for ${assetName}`);
}

function compareReleaseVersions(left, right) {
const leftMatch = /^v?(\d+)\.(\d+)\.(\d+)(.*)/.exec(left);
const rightMatch = /^v?(\d+)\.(\d+)\.(\d+)(.*)/.exec(right);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!leftMatch || !rightMatch) {
return null;
}
for (let i = 1; i <= 3; i++) {
const diff = Number(leftMatch[i]) - Number(rightMatch[i]);
if (diff !== 0) {
return diff;
}
}

function parseSuffix(suffix) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
let prerelease = null;
let build = null;
const buildIdx = suffix.indexOf("+");
if (buildIdx !== -1) {
build = suffix.slice(buildIdx + 1);
suffix = suffix.slice(0, buildIdx);
}
if (suffix.startsWith("-")) {
prerelease = suffix.slice(1);
}
return { prerelease, build };
}

const leftParsed = parseSuffix(leftMatch[4]);
const rightParsed = parseSuffix(rightMatch[4]);

// Build metadata is ignored for precedence.
const pre1 = leftParsed.prerelease;
const pre2 = rightParsed.prerelease;
if (pre1 === pre2) {
return 0;
}
if (pre1 === null) {
return 1;
}
if (pre2 === null) {
return -1;
}

const parts1 = pre1.split(".");
const parts2 = pre2.split(".");
const len = Math.max(parts1.length, parts2.length);
for (let i = 0; i < len; i++) {
const p1 = parts1[i];
const p2 = parts2[i];
if (p1 === undefined) {
return -1;
}
if (p2 === undefined) {
return 1;
}

const isNum1 = /^[0-9]+$/.test(p1);
const isNum2 = /^[0-9]+$/.test(p2);

if (isNum1 && isNum2) {
const n1 = Number(p1);
const n2 = Number(p2);
if (n1 !== n2) {
return n1 - n2;
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
} else if (isNum1 && !isNum2) {
return -1;
} else if (!isNum1 && isNum2) {
return 1;
} else {
if (p1 < p2) {
return -1;
}
if (p1 > p2) {
return 1;
}
}
}
return 0;
}

function isLegacyChecksumRelease(releaseVersion) {
const order = compareReleaseVersions(
releaseVersion,
SHA256_SIDECAR_REQUIRED_VERSION,
);
return order !== null && order < 0;
}

async function verifySha256(assetPath, checksumUrl, assetName) {
let checksumText;
try {
checksumText = await downloadText(checksumUrl);
} catch (err) {
if (err instanceof DownloadHttpError && err.statusCode === 404) {
if (isLegacyChecksumRelease(version) && allowLegacyNoChecksum) {
return false;
}
throw new Error(
`SHA-256 sidecar not found for ${assetName}: ${checksumUrl}`,
);
}
throw err;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const expected = parseExpectedSha256(checksumText, assetName);
const actual = await sha256File(assetPath);
if (actual !== expected) {
throw new Error(
`SHA-256 mismatch for ${assetName}: expected ${expected}, got ${actual}`,
);
}
}

function fmtBytes(n) {
if (n >= 1024 * 1024) {
return `${(n / 1024 / 1024).toFixed(1)} MB`;
Expand Down Expand Up @@ -242,6 +412,7 @@ async function main() {

if (!isCached || noCache) {
const assetUrl = `https://github.com/${REPO}/releases/download/v${version}/${entry.asset}`;
const checksumUrl = `${assetUrl}.sha256`;
const assetPath = path.join(cacheDir, entry.asset);
const tmpPath = `${assetPath}.download`;

Expand All @@ -250,8 +421,25 @@ async function main() {
console.log(`Downloading Risuko v${version} for ${platform}/${arch}…`);
console.log(` From: ${assetUrl}`);

await download(assetUrl, tmpPath);
fs.renameSync(tmpPath, assetPath);
try {
await download(assetUrl, tmpPath);
const checksumVerified = await verifySha256(
tmpPath,
checksumUrl,
entry.asset,
);
if (checksumVerified === false) {
console.warn(
` SHA-256 sidecar not found for legacy release v${version}; continuing without checksum verification`,
);
} else {
console.log(" Verified SHA-256 checksum");
}
fs.renameSync(tmpPath, assetPath);
} catch (err) {
fs.rmSync(tmpPath, { force: true });
throw err;
}
console.log(" Extracting…");
extract(entry, assetPath, cacheDir);
console.log(` Cached to: ${cacheDir}`);
Expand Down
3 changes: 0 additions & 3 deletions packages/risuko-app/index.js

This file was deleted.

2 changes: 1 addition & 1 deletion packages/risuko-app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@risuko/app",
"version": "0.3.6",
"version": "0.4.0",
"description": "Risuko download manager — launches the desktop app, downloading it from GitHub Releases on first run",
"license": "MIT",
"repository": {
Expand Down
5 changes: 4 additions & 1 deletion packages/risuko-cli/npm/darwin-arm64/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@risuko/cli-darwin-arm64",
"version": "0.3.6",
"version": "0.4.0",
"description": "Risuko CLI binary for macOS ARM64",
"repository": {
"type": "git",
Expand All @@ -14,6 +14,9 @@
"cpu": [
"arm64"
],
"scripts": {
"prepack": "node ../../../../scripts/ensure-package-artifacts.mjs"
},
"files": [
"risuko"
]
Expand Down
5 changes: 4 additions & 1 deletion packages/risuko-cli/npm/darwin-x64/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@risuko/cli-darwin-x64",
"version": "0.3.6",
"version": "0.4.0",
"description": "Risuko CLI binary for macOS x64",
"repository": {
"type": "git",
Expand All @@ -14,6 +14,9 @@
"cpu": [
"x64"
],
"scripts": {
"prepack": "node ../../../../scripts/ensure-package-artifacts.mjs"
},
"files": [
"risuko"
]
Expand Down
Loading
Loading