From b0e073471f98afb071c5089a040fecdb2c897616 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:10:15 +0200 Subject: [PATCH 1/4] Enforce analysis queue claims --- analyser/processQueue.sh | 51 +++++-- migrations/010_analysis_claim_tokens.sql | 19 +++ models/Apps.js | 167 +++++++++++++---------- routes/index.js | 34 ++++- scripts/queue-refetch.js | 1 + scripts/replay-ios-signatures.js | 37 +++-- test/appsState.test.js | 95 ++++++++++++- test/replay-ios-signatures.test.js | 30 ++++ 8 files changed, 336 insertions(+), 98 deletions(-) create mode 100644 migrations/010_analysis_claim_tokens.sql diff --git a/analyser/processQueue.sh b/analyser/processQueue.sh index faeae2b..94db701 100755 --- a/analyser/processQueue.sh +++ b/analyser/processQueue.sh @@ -113,7 +113,9 @@ fi curl_auth_config=$(mktemp) chmod 600 "$curl_auth_config" printf 'header = "Authorization: Bearer %s"\n' "$UPLOAD_PASSWORD" > "$curl_auth_config" -trap 'rm -f "$curl_auth_config"' EXIT +queue_headers=$(mktemp) +chmod 600 "$queue_headers" +trap 'rm -f "$curl_auth_config" "$queue_headers"' EXIT mkdir -p ipas mkdir -p classes @@ -408,20 +410,37 @@ urlencode() report_analysis_failure() { - appId="$1" + local appId="$1" + local claimToken="$2" curl -sS --fail -K "$curl_auth_config" \ "$SERVER/reportAnalysisFailure?appId=$(urlencode "$appId")&analysisVersion=$(urlencode "$ANALYSIS_VERSION")" \ + -H "X-Analysis-Claim-Token: $claimToken" \ --data-binary "@$log" -H "Content-Type: text/plain" > /dev/null } upload_analysis() { - appId="$1" + local appId="$1" + local claimToken="$2" curl -sS --fail -K "$curl_auth_config" \ "$SERVER/uploadAnalysis?appId=$(urlencode "$appId")&analysisVersion=$(urlencode "$ANALYSIS_VERSION")" \ + -H "X-Analysis-Claim-Token: $claimToken" \ -d @"analysis/$appId.json" -H "Content-Type: application/json" > /dev/null } +fetch_queue_app() +{ + local queueUrl="$1" + : > "$queue_headers" + appId=$(curl -sS -K "$curl_auth_config" -D "$queue_headers" "$queueUrl" --fail) || return 1 + claimToken=$(awk 'tolower($1) == "x-analysis-claim-token:" { gsub(/\r/, "", $2); print $2; exit }' "$queue_headers") + + if [ -n "$appId" ] && [ -z "$claimToken" ]; then + echo "Queue response for $appId did not include an analysis claim token." >&2 + return 1 + fi +} + download() { # ipatool's combined download path handles already-owned apps and purchases. @@ -641,7 +660,8 @@ fi process_app() { - appId="$1" + local appId="$1" + local claimToken="$2" success=0 # empty the log file @@ -697,7 +717,7 @@ process_app() if [ "$download_stop_reason" = "too-large" ] && [ ! -f "$f" ]; then show_log_tail - if report_analysis_failure "$appId"; then + if report_analysis_failure "$appId" "$claimToken"; then echo "Reported non-retryable oversized download for $appId." else echo "Failed to report oversized download for $appId." @@ -711,7 +731,7 @@ process_app() if [ "$size" -gt "$MAX_APP_SIZE_BYTES" ]; then echo "Skipping $appId: downloaded IPA is $((size / 1000000)) MB, above MAX_APP_SIZE_BYTES=$((MAX_APP_SIZE_BYTES / 1000000)) MB." >> "$log" show_log_tail - if report_analysis_failure "$appId"; then + if report_analysis_failure "$appId" "$claimToken"; then echo "Reported analysis failure for $appId." else echo "Failed to report analysis failure for $appId." @@ -739,7 +759,7 @@ process_app() if [ "$RUN_ONCE" = "1" ] && [ "$LIVE_LOG" != "1" ]; then show_log_tail fi - if upload_analysis "$appId"; then + if upload_analysis "$appId" "$claimToken"; then echo "Uploaded analysis for $appId." consecutive_failures=0 success=1 @@ -751,7 +771,7 @@ process_app() if [ ! -f "analysis/$appId.json" ]; then show_log_tail - if report_analysis_failure "$appId"; then + if report_analysis_failure "$appId" "$claimToken"; then echo "Reported analysis failure for $appId." else echo "Failed to report analysis failure for $appId." @@ -782,7 +802,12 @@ if [ -n "$ONLY_APP_ID" ]; then exit 1 fi - process_app "$ONLY_APP_ID" + if ! fetch_queue_app "$SERVER/queue?appId=$(urlencode "$ONLY_APP_ID")" || [ -z "$appId" ]; then + echo "Could not claim $ONLY_APP_ID for exact processing." + exit 1 + fi + + process_app "$appId" "$claimToken" exit $? fi @@ -805,12 +830,16 @@ while true; do fi echo "Fetching apps to install" - appId=`curl -s -K "$curl_auth_config" "$SERVER/queue" --fail` + if ! fetch_queue_app "$SERVER/queue"; then + echo "Failed to fetch a queue assignment." + appId="" + claimToken="" + fi if [ "$appId" == "" ] ; then echo "No app to process.." else - process_app "$appId" + process_app "$appId" "$claimToken" fi if [ "$RUN_ONCE" = "1" ]; then diff --git a/migrations/010_analysis_claim_tokens.sql b/migrations/010_analysis_claim_tokens.sql new file mode 100644 index 0000000..54011ad --- /dev/null +++ b/migrations/010_analysis_claim_tokens.sql @@ -0,0 +1,19 @@ +-- Tie every processing assignment to a one-use random claim token. Completion +-- compares this token before writing so a timed-out or explicitly reset worker +-- cannot overwrite the result of a newer assignment. + +ALTER TABLE apps + ADD COLUMN analysis_claim_token uuid; + +-- Existing processing rows predate claim tokens. Requeue them rather than +-- accepting an unverifiable completion during rollout. +UPDATE apps +SET status = 'queued', + processing_started = NULL +WHERE status = 'processing'; + +ALTER TABLE apps + ADD CONSTRAINT apps_processing_claim_token_check CHECK ( + (status = 'processing' AND analysis_claim_token IS NOT NULL) + OR (status <> 'processing' AND analysis_claim_token IS NULL) + ); diff --git a/models/Apps.js b/models/Apps.js index 9c88fa1..851ff91 100644 --- a/models/Apps.js +++ b/models/Apps.js @@ -1,8 +1,10 @@ const { Pool } = require('pg'); +const crypto = require('crypto'); const { APP_ID_PATTERN_SOURCE, MAX_APP_ID_LENGTH, isValidAppId, + isSameAppId, appIdSqlPredicate } = require('../lib/appId'); // index.js runs dotenv.config() before requiring this module (via server.js), @@ -41,6 +43,20 @@ function deriveAnalysisState(analysis) { }; } +const ANALYSIS_CLAIM_TOKEN_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function isValidAnalysisClaimToken(value) { + return typeof value === 'string' && ANALYSIS_CLAIM_TOKEN_PATTERN.test(value); +} + +function canonicalAppId(appId, details) { + if (!isValidAppId(appId)) throw new TypeError('Invalid App Store bundle ID'); + if (!details || !isValidAppId(details.appId) || !isSameAppId(details.appId, appId)) { + throw new TypeError('App Store bundle ID mismatch'); + } + return details.appId; +} + const lastAnalysed = async () => { const result = await pool.query("SELECT * FROM apps WHERE status = 'analysed' ORDER BY analysed DESC LIMIT 5"); return result.rows; @@ -82,12 +98,11 @@ const countQueue = async (added) => { } const addApp = async (appId, details) => { - if (!isValidAppId(appId)) throw new TypeError('Invalid App Store bundle ID'); - if (!details || details.appId !== appId) throw new TypeError('App Store bundle ID mismatch'); + const canonicalId = canonicalAppId(appId, details); // Bare ON CONFLICT covers both the appid primary key and the // case-insensitive lower(appid) unique index from migration 009. - const result = await pool.query('INSERT INTO apps (appid, details) VALUES ($1, $2) ON CONFLICT DO NOTHING', [appId, details]); + const result = await pool.query('INSERT INTO apps (appid, details) VALUES ($1, $2) ON CONFLICT DO NOTHING', [canonicalId, details]); return result; } @@ -101,7 +116,12 @@ const currentAnalysisVersion = CURRENT_ANALYSIS_VERSION; const staleAnalysisDays = STALE_ANALYSIS_DAYS; const processingTimeoutMinutes = PROCESSING_TIMEOUT_MINUTES; -const nextApp = async () => { +const nextApp = async (requestedAppId = null) => { + if (requestedAppId !== null && !isValidAppId(requestedAppId)) { + throw new TypeError('Invalid App Store bundle ID'); + } + + const claimToken = crypto.randomUUID(); const client = await pool.connect(); try { await client.query('BEGIN'); @@ -116,6 +136,7 @@ const nextApp = async () => { SELECT appid FROM apps WHERE ${appIdSqlPredicate(4, 5)} + AND ($6::text IS NULL OR lower(appid) = lower($6::text)) AND ( status = 'queued' OR ( @@ -146,7 +167,8 @@ const nextApp = async () => { staleAnalysisDays, processingTimeoutMinutes, APP_ID_PATTERN_SOURCE, - MAX_APP_ID_LENGTH + MAX_APP_ID_LENGTH, + requestedAppId ]); if (candidate.rowCount === 0) { @@ -157,10 +179,11 @@ const nextApp = async () => { const result = await client.query(` UPDATE apps SET status = 'processing', - processing_started = NOW() + processing_started = NOW(), + analysis_claim_token = $2 WHERE appid = $1 - RETURNING appid - `, [candidate.rows[0].appid]); + RETURNING appid, analysis_claim_token + `, [candidate.rows[0].appid, claimToken]); await client.query('COMMIT'); @@ -174,73 +197,76 @@ const nextApp = async () => { } }; -const updateAnalysis = async (appId, analysis, analysisVersion) => { +const updateAnalysisWithClient = async (client, appId, analysis, analysisVersion, claimToken) => { if (!isValidAppId(appId)) throw new TypeError('Invalid App Store bundle ID'); + if (!isValidAnalysisClaimToken(claimToken)) throw new TypeError('Invalid analysis claim token'); const { status, failureReason, failureRetryable } = deriveAnalysisState(analysis); - const client = await pool.connect(); - try { - await client.query('BEGIN'); + // The token comparison is the completion CAS: a timed-out worker cannot + // update a row after nextApp has issued a new token, and a reset/replay + // invalidates the claim by clearing it. + const result = await client.query( + `UPDATE apps + SET analysis = $1, + analysisVersion = $2, + analysed = NOW(), + status = $4, + failure_reason = $5, + failure_retryable = $6, + processing_started = NULL, + analysis_claim_token = NULL + WHERE appid = $3 + AND status = 'processing' + AND analysis_claim_token = $7 + RETURNING appid, details, analysed`, + [analysis, analysisVersion, appId, status, failureReason, failureRetryable, claimToken] + ); - // Keep writing the raw payload to apps.analysis (the website failure - // display and existing HTTP responses depend on it) AND set the new - // scheduling columns. processing_started is cleared now that the lock - // is resolved. - // - // Note: analysed is stamped with NOW() even when status ends up - // 'failed', so for failed apps this column really means "time of the - // last analysis attempt", not "time of the last successful analysis". - const result = await client.query( - `UPDATE apps - SET analysis = $1, - analysisVersion = $2, - analysed = NOW(), - status = $4, - failure_reason = $5, - failure_retryable = $6, - processing_started = NULL - WHERE appid = $3 - RETURNING appid, details, analysed`, - [analysis, analysisVersion, appId, status, failureReason, failureRetryable] - ); - - if (result.rowCount > 0) { - const app = result.rows[0]; - await client.query(` - INSERT INTO app_analyses ( - appid, - analysis, - analysisversion, - analysed, - app_version, - app_store_updated, - analysis_source, - success - ) - VALUES ( - $1, - $2, - $3, - $4, - $5, - NULLIF($6, '')::timestamp, - $7, - $8 - ) - ON CONFLICT (appid, analysed) DO NOTHING - `, [ - appId, + if (result.rowCount > 0) { + const app = result.rows[0]; + await client.query(` + INSERT INTO app_analyses ( + appid, analysis, - analysisVersion, - app.analysed, - app.details ? app.details.version : null, - app.details ? app.details.updated : null, - analysis && analysis.analysis_source ? analysis.analysis_source : 'legacy', - !(analysis && analysis.success === false) - ]); - } + analysisversion, + analysed, + app_version, + app_store_updated, + analysis_source, + success + ) + VALUES ( + $1, + $2, + $3, + $4, + $5, + NULLIF($6, '')::timestamp, + $7, + $8 + ) + ON CONFLICT (appid, analysed) DO NOTHING + `, [ + app.appid, + analysis, + analysisVersion, + app.analysed, + app.details ? app.details.version : null, + app.details ? app.details.updated : null, + analysis && analysis.analysis_source ? analysis.analysis_source : 'legacy', + !(analysis && analysis.success === false) + ]); + } + return result; +} + +const updateAnalysis = async (appId, analysis, analysisVersion, claimToken) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const result = await updateAnalysisWithClient(client, appId, analysis, analysisVersion, claimToken); await client.query('COMMIT'); return result; } catch (err) { @@ -293,5 +319,8 @@ module.exports = { getAllApps, getSiteDataSignature, healthCheck, - deriveAnalysisState + deriveAnalysisState, + canonicalAppId, + isValidAnalysisClaimToken, + updateAnalysisWithClient } diff --git a/routes/index.js b/routes/index.js index fe632fe..ad8088d 100644 --- a/routes/index.js +++ b/routes/index.js @@ -304,6 +304,7 @@ router.get('/analysis/:appId', asyncHandler(async (req, res) => { // Save to database try { await Apps.addApp(appId, app.details); + appId = app.details.appId; } catch (err) { console.log(err); @@ -337,12 +338,19 @@ router.get('/about', (req, res) => { // serve next task to analyser router.get('/queue', asyncHandler(async (req, res) => { - let app = await Apps.nextApp(); - console.log(app); + const requestedAppId = req.query.appId || null; + if (requestedAppId && !isValidAppId(requestedAppId)) + return res.status(400).send('Please provide a valid App Store bundle ID.'); + + let app = await Apps.nextApp(requestedAppId); + console.log(app ? app.appid : null); if (!app) return res.send(); + // Keep the body as the bundle ID for deployed analysers while newer + // clients carry the per-assignment token in a separate header. + res.set('X-Analysis-Claim-Token', app.analysis_claim_token); res.send(app.appid); })); @@ -363,15 +371,22 @@ router.post('/uploadAnalysis', asyncHandler(async (req, res) => { if (!isValidAppId(appId)) return res.status(400).send('Please provide a valid App Store bundle ID.'); + const claimToken = req.get('X-Analysis-Claim-Token'); + if (!Apps.isValidAnalysisClaimToken(claimToken)) + return res.status(400).send('Please provide a valid analysis claim token.'); + console.log('Updating', appId); if (!req.body) return res.status(400).end("Please provide valid JSON"); const analysis = req.body; - const result = await Apps.updateAnalysis(appId, analysis, analysisVersion); + const result = await Apps.updateAnalysis(appId, analysis, analysisVersion, claimToken); + if (result.rowCount === 0) + return res.status(409).send('Analysis claim is no longer active.'); + cache.invalidate('sitedata'); - res.send(result); + res.json({ ok: true }); })); // avoid a loop: only analyse each app once @@ -382,6 +397,10 @@ router.post('/reportAnalysisFailure', asyncHandler(async (req, res) => { if (!isValidAppId(req.query.appId)) return res.status(400).send('Please provide a valid App Store bundle ID.'); + const claimToken = req.get('X-Analysis-Claim-Token'); + if (!Apps.isValidAnalysisClaimToken(claimToken)) + return res.status(400).send('Please provide a valid analysis claim token.'); + const logs = req.body; // should contain the log const failure = classifyAnalysisFailure(logs); console.log('Removing from queue', req.query.appId, logs); @@ -391,9 +410,12 @@ router.post('/reportAnalysisFailure', asyncHandler(async (req, res) => { logs: logs, reason: failure.reason, retryable: failure.retryable - }, req.query.analysisVersion); + }, req.query.analysisVersion, claimToken); + if (result.rowCount === 0) + return res.status(409).send('Analysis claim is no longer active.'); + cache.invalidate('sitedata'); - res.send(result); + res.json({ ok: true }); })); /*router.get('/sitemap.xml', async (req, res) => { diff --git a/scripts/queue-refetch.js b/scripts/queue-refetch.js index 949faec..0da91ba 100644 --- a/scripts/queue-refetch.js +++ b/scripts/queue-refetch.js @@ -134,6 +134,7 @@ async function main() { analysed = NULL, status = 'queued', processing_started = NULL, + analysis_claim_token = NULL, failure_reason = NULL, failure_retryable = NULL WHERE appid = $1`, diff --git a/scripts/replay-ios-signatures.js b/scripts/replay-ios-signatures.js index 13fe823..72c2e48 100644 --- a/scripts/replay-ios-signatures.js +++ b/scripts/replay-ios-signatures.js @@ -246,13 +246,7 @@ async function fetchCurrentAnalyses(appIds) { } } -async function applyReplay(rows) { - if (!process.env.DATABASE_URL) { - throw new Error('DATABASE_URL is required with --apply'); - } - - const client = new Client({ connectionString: process.env.DATABASE_URL }); - await client.connect(); +async function applyReplayRows(client, rows) { let inTransaction = false; try { const history = await client.query("SELECT to_regclass('public.app_analyses') AS table_name"); @@ -294,16 +288,36 @@ async function applyReplay(rows) { ) `, [row.bundleID]); - await client.query( - 'UPDATE apps SET analysis = $1, analysisversion = $2, analysed = NOW() WHERE appid = $3', - [row.analysis, row.analysisVersion, row.bundleID] - ); + await client.query(` + UPDATE apps + SET analysis = $1, + analysisversion = $2, + analysed = NOW(), + status = 'analysed', + processing_started = NULL, + analysis_claim_token = NULL, + failure_reason = NULL, + failure_retryable = NULL + WHERE appid = $3 + `, [row.analysis, row.analysisVersion, row.bundleID]); } await client.query('COMMIT'); inTransaction = false; } catch (error) { if (inTransaction) await client.query('ROLLBACK'); throw error; + } +} + +async function applyReplay(rows) { + if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL is required with --apply'); + } + + const client = new Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + try { + await applyReplayRows(client, rows); } finally { await client.end(); } @@ -375,6 +389,7 @@ if (require.main === module) { } module.exports = { + applyReplayRows, diffTrackers, fullClassesFromRaw, latestTrackerscanArtifacts, diff --git a/test/appsState.test.js b/test/appsState.test.js index cb72c40..92f33af 100644 --- a/test/appsState.test.js +++ b/test/appsState.test.js @@ -1,6 +1,55 @@ const assert = require('node:assert/strict'); const test = require('node:test'); -const { deriveAnalysisState } = require('../models/Apps'); +const { + canonicalAppId, + deriveAnalysisState, + updateAnalysisWithClient +} = require('../models/Apps'); + +const ACTIVE_TOKEN = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const STALE_TOKEN = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + +function claimedAppClient(activeToken = ACTIVE_TOKEN) { + const state = { + appid: 'com.example.App', + status: 'processing', + analysisClaimToken: activeToken, + analysis: null, + history: [] + }; + + return { + state, + async query(sql, params) { + if (/UPDATE apps/.test(sql)) { + assert.match(sql, /AND status = 'processing'/); + assert.match(sql, /AND analysis_claim_token = \$7/); + assert.match(sql, /analysis_claim_token = NULL/); + const matches = state.status === 'processing' && state.analysisClaimToken === params[6]; + if (!matches) return { rowCount: 0, rows: [] }; + + state.analysis = params[0]; + state.status = params[3]; + state.analysisClaimToken = null; + return { + rowCount: 1, + rows: [{ + appid: state.appid, + details: { version: '1.0', updated: '2026-01-01T00:00:00Z' }, + analysed: new Date('2026-01-02T00:00:00Z') + }] + }; + } + + if (/INSERT INTO app_analyses/.test(sql)) { + state.history.push(params); + return { rowCount: 1, rows: [] }; + } + + throw new Error(`Unexpected query: ${sql}`); + } + }; +} test('successful analysis maps to analysed with no failure fields', () => { assert.deepEqual( @@ -36,3 +85,47 @@ test('failure falls back to logs when reason is absent', () => { { status: 'failed', failureReason: 'raw log text', failureRetryable: true } ); }); + +test('accepts mixed-case requests and uses App Store canonical bundle ID', () => { + assert.equal( + canonicalAppId('COM.Example.app', { appId: 'com.example.App' }), + 'com.example.App' + ); + assert.throws( + () => canonicalAppId('com.example.app', { appId: 'com.other.app' }), + /bundle ID mismatch/ + ); +}); + +test('active analysis claim completes and consumes its token', async () => { + const client = claimedAppClient(); + const result = await updateAnalysisWithClient( + client, + 'com.example.App', + { success: true, trackers: {} }, + 4, + ACTIVE_TOKEN + ); + + assert.equal(result.rowCount, 1); + assert.equal(client.state.status, 'analysed'); + assert.equal(client.state.analysisClaimToken, null); + assert.equal(client.state.history.length, 1); +}); + +test('stale analysis claim cannot overwrite a newer assignment', async () => { + const client = claimedAppClient(ACTIVE_TOKEN); + const result = await updateAnalysisWithClient( + client, + 'com.example.App', + { success: false, reason: 'stale result' }, + 4, + STALE_TOKEN + ); + + assert.equal(result.rowCount, 0); + assert.equal(client.state.status, 'processing'); + assert.equal(client.state.analysisClaimToken, ACTIVE_TOKEN); + assert.equal(client.state.analysis, null); + assert.equal(client.state.history.length, 0); +}); diff --git a/test/replay-ios-signatures.test.js b/test/replay-ios-signatures.test.js index 4b7809a..4055696 100644 --- a/test/replay-ios-signatures.test.js +++ b/test/replay-ios-signatures.test.js @@ -97,3 +97,33 @@ test('trackerscan converter exposes only visible v3 signatures', () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + +test('applying a successful replay synchronizes scheduling state', async () => { + const queries = []; + const client = { + async query(sql, params) { + queries.push({ sql, params }); + if (/to_regclass/.test(sql)) return { rows: [{ table_name: 'app_analyses' }] }; + return { rowCount: 1, rows: [] }; + } + }; + + await replay.applyReplayRows(client, [{ + bundleID: 'com.example.app', + analysis: { success: true, trackers: {} }, + analysisVersion: 4 + }]); + + const update = queries.find(({ sql }) => /UPDATE apps/.test(sql)); + assert.ok(update); + assert.match(update.sql, /status = 'analysed'/); + assert.match(update.sql, /processing_started = NULL/); + assert.match(update.sql, /analysis_claim_token = NULL/); + assert.match(update.sql, /failure_reason = NULL/); + assert.match(update.sql, /failure_retryable = NULL/); + assert.deepEqual(update.params, [ + { success: true, trackers: {} }, + 4, + 'com.example.app' + ]); +}); From 89630b18d6a9f0addb9a6e306a67a8600a21df41 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:12:54 +0200 Subject: [PATCH 2/4] Document analysis claim rollout --- README.md | 18 +++++++++++++++--- test/auth.test.js | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 74ed70d..0e5004c 100644 --- a/README.md +++ b/README.md @@ -153,12 +153,24 @@ See [raspberry-pi-analyser.md](raspberry-pi-analyser.md) for the full setup, inc The website exposes analyser endpoints: -- `GET /queue` returns the next app to process. +- `GET /queue` returns the next app to process in the response body and its one-use assignment token in the `X-Analysis-Claim-Token` response header. - `GET /ping` marks the analyser online. -- `POST /uploadAnalysis` stores successful analysis results. -- `POST /reportAnalysisFailure` stores failed analysis results. +- `POST /uploadAnalysis` stores successful analysis results when sent with the assignment's `X-Analysis-Claim-Token` header. +- `POST /reportAnalysisFailure` stores failed analysis results when sent with the assignment's `X-Analysis-Claim-Token` header. Analyzer requests authenticate with `Authorization: Bearer $UPLOAD_PASSWORD`. +Authentication and assignment tokens serve different purposes: the bearer token authorises the analyser, while the assignment token prevents an expired worker from overwriting a newer result. + +### Claim-token rollout + +The claim-token migration deliberately requeues every in-flight assignment that predates tokens. Use a coordinated deployment: + +1. Stop all analyser queue processors. +2. Deploy the website and run `npm run migrate` (or start it with `npm run start`). +3. Deploy the matching `analyser/processQueue.sh` to every analyser host. +4. Restart the analysers. + +The `/queue` response body remains a plain bundle ID, but older analyser scripts do not return the required claim header. Their completion requests will be rejected and must not remain running after the migration. Any interrupted work is safely available to the updated analysers because the migration requeues it. Health checks: diff --git a/test/auth.test.js b/test/auth.test.js index c7dd798..d73abfb 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -26,3 +26,45 @@ test('analyser endpoints accept bearer auth and reject missing auth', async () = }); } }); + +test('analysis upload rejects a stale claim with conflict', async () => { + process.env.UPLOAD_PASSWORD = 'test-secret'; + const Apps = require('../models/Apps'); + const originalUpdateAnalysis = Apps.updateAnalysis; + const claimToken = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + Apps.updateAnalysis = async (appId, analysis, analysisVersion, token) => { + assert.equal(appId, 'com.example.app'); + assert.deepEqual(analysis, { success: true, trackers: {} }); + assert.equal(analysisVersion, '4'); + assert.equal(token, claimToken); + return { rowCount: 0, rows: [] }; + }; + + const app = require('../server'); + const server = await new Promise((resolve) => { + const instance = app.listen(0, () => resolve(instance)); + }); + + try { + const response = await fetch( + `http://127.0.0.1:${server.address().port}/uploadAnalysis?appId=com.example.app&analysisVersion=4`, + { + method: 'POST', + headers: { + authorization: 'Bearer test-secret', + 'content-type': 'application/json', + 'x-analysis-claim-token': claimToken + }, + body: JSON.stringify({ success: true, trackers: {} }) + } + ); + + assert.equal(response.status, 409); + assert.match(await response.text(), /claim is no longer active/i); + } finally { + Apps.updateAnalysis = originalUpdateAnalysis; + await new Promise((resolve, reject) => { + server.close((err) => err ? reject(err) : resolve()); + }); + } +}); From 22f6284e4cc46fab5298ef3c2f8abb0c76d92dbe Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:56:11 +0200 Subject: [PATCH 3/4] Remove unused analysis route reassignment --- routes/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/routes/index.js b/routes/index.js index ad8088d..5fb6110 100644 --- a/routes/index.js +++ b/routes/index.js @@ -304,7 +304,6 @@ router.get('/analysis/:appId', asyncHandler(async (req, res) => { // Save to database try { await Apps.addApp(appId, app.details); - appId = app.details.appId; } catch (err) { console.log(err); From 6b743bbf2ebdd69a2d0dd8ec3661e55e89d8b6cf Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:01:39 +0200 Subject: [PATCH 4/4] Update request handling test for analysis claims --- test/requestHandling.test.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/requestHandling.test.js b/test/requestHandling.test.js index fa2f175..f152e23 100644 --- a/test/requestHandling.test.js +++ b/test/requestHandling.test.js @@ -93,13 +93,14 @@ test('large body parsers are scoped to authenticated analyser endpoints', async method: 'POST', headers: { authorization: 'Bearer test-secret', - 'content-type': 'application/json' + 'content-type': 'application/json', + 'x-analysis-claim-token': 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, body: jsonPayload } ); assert.equal(uploadResponse.status, 200); - assert.equal(await uploadResponse.text(), 'updated'); + assert.deepEqual(await uploadResponse.json(), { ok: true }); const failureResponse = await fetch( `${base}/reportAnalysisFailure?appId=com.example.app&analysisVersion=1`, @@ -107,13 +108,14 @@ test('large body parsers are scoped to authenticated analyser endpoints', async method: 'POST', headers: { authorization: 'Bearer test-secret', - 'content-type': 'text/plain' + 'content-type': 'text/plain', + 'x-analysis-claim-token': 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, body: textPayload } ); assert.equal(failureResponse.status, 200); - assert.equal(await failureResponse.text(), 'updated'); + assert.deepEqual(await failureResponse.json(), { ok: true }); const oversizedUploadResponse = await fetch( `${base}/uploadAnalysis?appId=com.example.app&analysisVersion=1`,