From ee2e6c638697dd804703286f7da7288cfde95f5b Mon Sep 17 00:00:00 2001 From: Emma Date: Sun, 26 Jul 2026 05:18:56 +0300 Subject: [PATCH 1/2] Korjaa DeepSec-turvatarkistusten kattavuus --- .deepsec/.gitignore | 1 + .../matchers/android-exported-component.ts | 2 +- .../android-uri-share-without-clipdata.ts | 56 +++++++-- .deepsec/matchers/fileprovider-broad-path.ts | 3 +- .../foreground-audio-service-start.ts | 2 +- .../matchers/health-connect-sensitive-flow.ts | 8 +- .deepsec/matchers/security-matchers.test.ts | 116 ++++++++++++++++++ .deepsec/matchers/sensitive-android-log.ts | 51 ++++++-- .deepsec/package.json | 1 + .deepsec/pnpm-lock.yaml | 20 ++- .deepsec/pnpm-workspace.yaml | 1 + .deepsec/scripts/recover-processing-locks.mjs | 38 +++--- .../scripts/recover-processing-locks.test.mjs | 35 +++++- 13 files changed, 285 insertions(+), 49 deletions(-) create mode 100644 .deepsec/matchers/security-matchers.test.ts diff --git a/.deepsec/.gitignore b/.deepsec/.gitignore index 36b1f05..c2e7df7 100644 --- a/.deepsec/.gitignore +++ b/.deepsec/.gitignore @@ -6,3 +6,4 @@ data/*/runs/ data/*/reports/ findings/ comment.md +.tmp/ diff --git a/.deepsec/matchers/android-exported-component.ts b/.deepsec/matchers/android-exported-component.ts index a1efd26..c32c954 100644 --- a/.deepsec/matchers/android-exported-component.ts +++ b/.deepsec/matchers/android-exported-component.ts @@ -11,7 +11,7 @@ export const androidExportedComponent: MatcherPlugin = { return regexCandidates("android-exported-component", content, [ { regex: - /<(activity|activity-alias|service|receiver)\b[\s\S]*?android:exported\s*=\s*"true"[\s\S]*?(?:<\/\1>|\/>)/, + /<(activity|activity-alias|service|receiver)\b[\s\S]*?android:exported\s*=\s*(["'])true\2[\s\S]*?(?:<\/\1>|\/>)/, label: 'Android component with android:exported="true"', }, ]); diff --git a/.deepsec/matchers/android-uri-share-without-clipdata.ts b/.deepsec/matchers/android-uri-share-without-clipdata.ts index 125364f..55b0487 100644 --- a/.deepsec/matchers/android-uri-share-without-clipdata.ts +++ b/.deepsec/matchers/android-uri-share-without-clipdata.ts @@ -1,6 +1,8 @@ import type { CandidateMatch, MatcherPlugin } from "deepsec/config"; import { candidate, isTestFile } from "./utils.js"; +const sendAction = /\bIntent\.ACTION_SEND(?:_MULTIPLE)?\b/g; + export const androidUriShareWithoutClipData: MatcherPlugin = { slug: "android-uri-share-without-clipdata", description: @@ -12,23 +14,55 @@ export const androidUriShareWithoutClipData: MatcherPlugin = { if (!content.includes("Intent.ACTION_SEND") && !content.includes("Intent.ACTION_SEND_MULTIPLE")) return []; if (!content.includes("Intent.EXTRA_STREAM")) return []; - const hasReadGrant = content.includes("FLAG_GRANT_READ_URI_PERMISSION"); - const hasClipData = /\bclipData\b|ClipData\./.test(content); - if (hasReadGrant && hasClipData) return []; + return shareScopes(content).flatMap(({ start, text }) => { + if (!text.includes("Intent.EXTRA_STREAM")) return []; - const sendIndex = content.indexOf("Intent.ACTION_SEND"); - const sendMultipleIndex = content.indexOf("Intent.ACTION_SEND_MULTIPLE"); - const index = [sendIndex, sendMultipleIndex].filter((value) => value >= 0).sort((a, b) => a - b)[0] ?? 0; + const hasReadGrant = text.includes("FLAG_GRANT_READ_URI_PERMISSION"); + const hasClipData = /\bclipData\b|ClipData\./.test(text); + if (hasReadGrant && hasClipData) return []; - return [ - candidate( + return candidate( "android-uri-share-without-clipdata", content, - index, + start, hasReadGrant ? "EXTRA_STREAM content URI share without ClipData" : "EXTRA_STREAM content URI share without FLAG_GRANT_READ_URI_PERMISSION", - ), - ]; + ); + }); }, }; + +function shareScopes(content: string): Array<{ start: number; text: string }> { + const starts = [...content.matchAll(sendAction)].map((match) => match.index ?? 0); + return starts.map((start, index) => { + const nextShareStart = starts[index + 1] ?? content.length; + const enclosingBlockEnd = findEnclosingBlockEnd(content, start); + const end = Math.min(nextShareStart, enclosingBlockEnd); + return { start, text: content.slice(start, end) }; + }); +} + +function findEnclosingBlockEnd(content: string, index: number): number { + const openBraces: number[] = []; + for (let cursor = 0; cursor < index; cursor += 1) { + if (content[cursor] === "{") { + openBraces.push(cursor); + } else if (content[cursor] === "}") { + openBraces.pop(); + } + } + + if (openBraces.length === 0) return content.length; + + let depth = 1; + for (let cursor = index; cursor < content.length; cursor += 1) { + if (content[cursor] === "{") { + depth += 1; + } else if (content[cursor] === "}") { + depth -= 1; + if (depth === 0) return cursor; + } + } + return content.length; +} diff --git a/.deepsec/matchers/fileprovider-broad-path.ts b/.deepsec/matchers/fileprovider-broad-path.ts index 139e2fe..54f2e64 100644 --- a/.deepsec/matchers/fileprovider-broad-path.ts +++ b/.deepsec/matchers/fileprovider-broad-path.ts @@ -14,7 +14,8 @@ export const fileproviderBroadPath: MatcherPlugin = { { regex: /]*>/i, label: "FileProvider root-path exposes filesystem root" }, { regex: /]*>/i, label: "FileProvider external-path exposes shared storage" }, { - regex: /<(?:files-path|cache-path|external-files-path|external-cache-path)\b[^>]*\bpath\s*=\s*"[\s.\/]*"[^>]*>/i, + regex: + /<(?:files-path|cache-path|external-files-path|external-cache-path)\b[^>]*\bpath\s*=\s*(["'])[\s.\/]*\1[^>]*>/i, label: 'FileProvider path="." or equivalent broad directory', }, ]); diff --git a/.deepsec/matchers/foreground-audio-service-start.ts b/.deepsec/matchers/foreground-audio-service-start.ts index cb4268e..8a60f56 100644 --- a/.deepsec/matchers/foreground-audio-service-start.ts +++ b/.deepsec/matchers/foreground-audio-service-start.ts @@ -17,7 +17,7 @@ export const foregroundAudioServiceStart: MatcherPlugin = { label: "Audio session start from foreground service", }, { - regex: /ServiceCompat\.startForeground\s*\(/, + regex: /\b(?:ServiceCompat\.)?startForeground\s*\(/, label: "Foreground promotion call", }, ]); diff --git a/.deepsec/matchers/health-connect-sensitive-flow.ts b/.deepsec/matchers/health-connect-sensitive-flow.ts index fe4b3da..30048de 100644 --- a/.deepsec/matchers/health-connect-sensitive-flow.ts +++ b/.deepsec/matchers/health-connect-sensitive-flow.ts @@ -9,7 +9,13 @@ export const healthConnectSensitiveFlow: MatcherPlugin = { filePatterns: ["app/src/main/**/*.kt", "app/src/main/AndroidManifest.xml"], match(content, filePath): CandidateMatch[] { if (isTestFile(filePath)) return []; - if (!/HealthConnect|health\.|HeartRate|ExerciseSession|WRITE_EXERCISE|READ_HEART_RATE/.test(content)) return []; + if ( + !/HealthConnect|health\.|HeartRate|ExerciseSession|WRITE_EXERCISE|READ_HEART_RATE|HealthPermission\.get(?:Write|Read)Permission|writeNoiseDose/.test( + content, + ) + ) { + return []; + } return regexCandidates("health-connect-sensitive-flow", content, [ { regex: /WRITE_EXERCISE|HealthPermission\.getWritePermission/, label: "Health Connect write permission" }, diff --git a/.deepsec/matchers/security-matchers.test.ts b/.deepsec/matchers/security-matchers.test.ts new file mode 100644 index 0000000..892c541 --- /dev/null +++ b/.deepsec/matchers/security-matchers.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { androidExportedComponent } from "./android-exported-component.js"; +import { androidUriShareWithoutClipData } from "./android-uri-share-without-clipdata.js"; +import { fileproviderBroadPath } from "./fileprovider-broad-path.js"; +import { foregroundAudioServiceStart } from "./foreground-audio-service-start.js"; +import { healthConnectSensitiveFlow } from "./health-connect-sensitive-flow.js"; +import { sensitiveAndroidLog } from "./sensitive-android-log.js"; + +test("health matcher gates on permission helpers and noise-dose writes", () => { + const writePermission = healthConnectSensitiveFlow.match( + "HealthPermission.getWritePermission(ExerciseSessionRecord::class)", + "app/src/main/java/com/dbcheck/app/HealthPermissions.kt", + ); + const noiseDose = healthConnectSensitiveFlow.match( + "suspend fun sync() = writeNoiseDose(report)", + "app/src/main/java/com/dbcheck/app/HealthSync.kt", + ); + + assert.ok(writePermission.some((match) => match.matchedPattern === "Health Connect write permission")); + assert.ok(noiseDose.some((match) => match.matchedPattern === "Noise-dose write flow")); +}); + +test("sensitive log matcher covers multiline calls", () => { + const matches = sensitiveAndroidLog.match( + `Log.i( + TAG, + "Exported session URI: $uri", + )`, + "app/src/main/java/com/dbcheck/app/ExportLogger.kt", + ); + + assert.equal(matches.length, 1); +}); + +test("sensitive log matcher does not consume a later Kotlin statement", () => { + const matches = sensitiveAndroidLog.match( + `Log.i(TAG, "sync complete") +writeNoiseDose(report)`, + "app/src/main/java/com/dbcheck/app/HealthSync.kt", + ); + + assert.deepEqual(matches, []); +}); + +test("foreground matcher covers direct and ServiceCompat promotion", () => { + const direct = foregroundAudioServiceStart.match( + "class MeasurementForegroundService { fun promote() = startForeground(ID, notification) }", + "app/src/main/java/com/dbcheck/app/MeasurementForegroundService.kt", + ); + const compatible = foregroundAudioServiceStart.match( + "class MeasurementForegroundService { fun promote() = ServiceCompat.startForeground(this, ID, notification, 0) }", + "app/src/main/java/com/dbcheck/app/MeasurementForegroundService.kt", + ); + + assert.equal(direct.length, 1); + assert.equal(compatible.length, 1); +}); + +test("FileProvider matcher covers single-quoted broad paths", () => { + const matches = fileproviderBroadPath.match( + "", + "app/src/main/res/xml/file_paths.xml", + ); + + assert.equal(matches.length, 1); +}); + +test("exported component matcher covers single-quoted attributes", () => { + const matches = androidExportedComponent.match( + "", + "app/src/main/AndroidManifest.xml", + ); + + assert.equal(matches.length, 1); +}); + +test("URI share matcher evaluates each share construction independently", () => { + const content = ` +fun safe(uri: Uri): Intent = + Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + clipData = ClipData.newUri(resolver, "safe", uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + +fun unsafe(uri: Uri): Intent = + Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + } +`; + + const matches = androidUriShareWithoutClipData.match( + content, + "app/src/main/java/com/dbcheck/app/ShareFactory.kt", + ); + + assert.equal(matches.length, 1); + assert.equal( + matches[0]?.matchedPattern, + "EXTRA_STREAM content URI share without FLAG_GRANT_READ_URI_PERMISSION", + ); +}); + +test("URI share matcher reports a missing ClipData in an otherwise granted scope", () => { + const matches = androidUriShareWithoutClipData.match( + `fun share(uri: Uri) = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(uri)) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }`, + "app/src/main/java/com/dbcheck/app/ShareFactory.kt", + ); + + assert.equal(matches.length, 1); + assert.equal(matches[0]?.matchedPattern, "EXTRA_STREAM content URI share without ClipData"); +}); diff --git a/.deepsec/matchers/sensitive-android-log.ts b/.deepsec/matchers/sensitive-android-log.ts index cc9c690..f1c091a 100644 --- a/.deepsec/matchers/sensitive-android-log.ts +++ b/.deepsec/matchers/sensitive-android-log.ts @@ -1,8 +1,10 @@ import type { CandidateMatch, MatcherPlugin } from "deepsec/config"; -import { isTestFile, regexCandidates } from "./utils.js"; +import { candidate, isTestFile } from "./utils.js"; const sensitiveWords = "(?:session|sessionId|measurement|decibel|dbWeighted|dbcheck\\.db|audio|microphone|recording|sample|billing|purchase|token|backup|restore|health|heart|threshold|hearing|csv|pdf|export|uri|fileprovider)"; +const sensitiveWordPattern = new RegExp(sensitiveWords, "i"); +const logCallStart = /\b(?:Log|android\.util\.Log)\.(?:v|d|i|w|e)\s*\(/g; export const sensitiveAndroidLog: MatcherPlugin = { slug: "sensitive-android-log", @@ -13,14 +15,43 @@ export const sensitiveAndroidLog: MatcherPlugin = { match(content, filePath): CandidateMatch[] { if (isTestFile(filePath)) return []; - return regexCandidates("sensitive-android-log", content, [ - { - regex: new RegExp( - String.raw`\b(?:Log|android\.util\.Log)\.(?:v|d|i|w|e)\s*\([^;\n]*${sensitiveWords}[^;\n]*\)`, - "i", - ), - label: "Sensitive term in Android log call", - }, - ]); + return [...content.matchAll(logCallStart)].flatMap((match) => { + const start = match.index ?? 0; + const openParenthesis = start + match[0].lastIndexOf("("); + const end = findCallEnd(content, openParenthesis); + if (end === null || !sensitiveWordPattern.test(content.slice(openParenthesis + 1, end))) return []; + + return candidate("sensitive-android-log", content, start, "Sensitive term in Android log call"); + }); }, }; + +function findCallEnd(content: string, openParenthesis: number): number | null { + let depth = 0; + let quote: "'" | '"' | null = null; + let escaped = false; + + for (let cursor = openParenthesis; cursor < content.length; cursor += 1) { + const character = content[cursor]; + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === quote) { + quote = null; + } + continue; + } + + if (character === "'" || character === '"') { + quote = character; + } else if (character === "(") { + depth += 1; + } else if (character === ")") { + depth -= 1; + if (depth === 0) return cursor; + } + } + return null; +} diff --git a/.deepsec/package.json b/.deepsec/package.json index 2258f60..f4f74fd 100644 --- a/.deepsec/package.json +++ b/.deepsec/package.json @@ -11,6 +11,7 @@ "deepsec:export": "deepsec export --project-id dbcheck --format md-dir --out ./findings", "deepsec:report": "pnpm run deepsec:scan && pnpm run deepsec:recover -- --fail-on-active && pnpm run deepsec:process && pnpm run deepsec:export", "deepsec:report:custom": "pnpm run deepsec:scan:custom && pnpm run deepsec:recover -- --fail-on-active && pnpm run deepsec:process && pnpm run deepsec:export", + "test:matchers": "tsc --project tsconfig.json --noEmit false --outDir .tmp/test-build && node --test .tmp/test-build/matchers/security-matchers.test.js", "test:recover-locks": "node --test ./scripts/recover-processing-locks.test.mjs" }, "dependencies": { diff --git a/.deepsec/pnpm-lock.yaml b/.deepsec/pnpm-lock.yaml index aa93555..e4cd8ed 100644 --- a/.deepsec/pnpm-lock.yaml +++ b/.deepsec/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: qs: 6.15.2 hono: 4.12.27 + '@hono/node-server': 2.0.11 importers: @@ -42,21 +43,25 @@ packages: resolution: {integrity: sha512-qfFBwOf0uh/mAtNGEkBJlLWt1XIgFqcQeeX966g2NexasCdSJGnwfc0YWA0QOeAAU/5xk6tSCW/Nn0zHGBO/3A==} cpu: [arm64] os: [linux] + libc: [musl] '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.218': resolution: {integrity: sha512-Y6vFEnz6wwd+rYpVGsPqgeXZuZP7NvQg6vjkC+N6cs1+I41LdKjfs+F+WG3daIqgt+vJBz/EB5Q0PByABkwufA==} cpu: [arm64] os: [linux] + libc: [glibc] '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.218': resolution: {integrity: sha512-e47oi8dYWnS0wx2/F6FL3YhaD2HedMd2nhI/nududP4PBzytuXeaDE/sJ0eIqtUVkl6/bn5uBhtIEHHWoj37JQ==} cpu: [x64] os: [linux] + libc: [musl] '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.218': resolution: {integrity: sha512-gCq/i7yRXeuoQXidGQynuiw5AeczQXSIifxH5Vpr9OFDyHCBNotS1mQ8qutuclZeFpLOPkJ2ic/nvEgHJvfXdg==} cpu: [x64] os: [linux] + libc: [glibc] '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.218': resolution: {integrity: sha512-D0UKI8jOpEsWO1A+i0DlIrgjXFKEZX3RrID2l49S4pShUlLsnSLJGMTPy4nQt6CCl5MzPqWiJDE/lCpImFTDcg==} @@ -222,9 +227,9 @@ packages: '@modelcontextprotocol/sdk': optional: true - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.0.11': + resolution: {integrity: sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA==} + engines: {node: '>=20'} peerDependencies: hono: 4.12.27 @@ -254,30 +259,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-arm64-musl@0.3.9': resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.9': resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.9': resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} @@ -1646,7 +1656,7 @@ snapshots: - supports-color - utf-8-validate - '@hono/node-server@1.19.14(hono@4.12.27)': + '@hono/node-server@2.0.11(hono@4.12.27)': dependencies: hono: 4.12.27 @@ -1712,7 +1722,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.27) + '@hono/node-server': 2.0.11(hono@4.12.27) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 diff --git a/.deepsec/pnpm-workspace.yaml b/.deepsec/pnpm-workspace.yaml index b2af0e9..7f9ffe1 100644 --- a/.deepsec/pnpm-workspace.yaml +++ b/.deepsec/pnpm-workspace.yaml @@ -1,6 +1,7 @@ overrides: qs: 6.15.2 hono: 4.12.27 + '@hono/node-server': 2.0.11 allowBuilds: '@google/genai': false diff --git a/.deepsec/scripts/recover-processing-locks.mjs b/.deepsec/scripts/recover-processing-locks.mjs index a7594c9..1957152 100644 --- a/.deepsec/scripts/recover-processing-locks.mjs +++ b/.deepsec/scripts/recover-processing-locks.mjs @@ -39,6 +39,11 @@ export function recoverProcessingLocks({ now, }); if (!reason) { + remaining.push({ + filePath: record.filePath, + lockedByRunId: record.lockedByRunId, + lockedAt: record.lockedAt, + }); continue; } @@ -56,19 +61,6 @@ export function recoverProcessingLocks({ } } - for (const file of listJsonFiles(filesDir)) { - const record = readJson(file); - if (record.status !== "processing") { - continue; - } - - remaining.push({ - filePath: record.filePath, - lockedByRunId: record.lockedByRunId, - lockedAt: record.lockedAt, - }); - } - if (!dryRun) { markRecoveredRuns({ runsDir, recovered, now }); } @@ -171,13 +163,17 @@ function parseArgs(argv) { if (arg === "--") { continue; } else if (arg === "--data-dir") { - args.dataDir = path.resolve(argv[++i]); + args.dataDir = path.resolve(optionValue(argv, i, arg)); + i += 1; } else if (arg === "--project-id") { - args.projectId = argv[++i]; + args.projectId = optionValue(argv, i, arg); + i += 1; } else if (arg === "--stale-minutes") { - args.staleMinutes = Number(argv[++i]); + args.staleMinutes = Number(optionValue(argv, i, arg)); + i += 1; } else if (arg === "--force-run-id") { - args.forceRunIds.push(...argv[++i].split(",").filter(Boolean)); + args.forceRunIds.push(...optionValue(argv, i, arg).split(",").filter(Boolean)); + i += 1; } else if (arg === "--dry-run") { args.dryRun = true; } else if (arg === "--fail-on-active") { @@ -194,6 +190,14 @@ function parseArgs(argv) { return args; } +function optionValue(argv, index, option) { + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`Missing value for ${option}`); + } + return value; +} + function main() { const args = parseArgs(process.argv.slice(2)); const result = recoverProcessingLocks(args); diff --git a/.deepsec/scripts/recover-processing-locks.test.mjs b/.deepsec/scripts/recover-processing-locks.test.mjs index edea795..3ab08cb 100644 --- a/.deepsec/scripts/recover-processing-locks.test.mjs +++ b/.deepsec/scripts/recover-processing-locks.test.mjs @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { recoverProcessingLocks } from "./recover-processing-locks.mjs"; @@ -93,7 +93,7 @@ test("reports multiple unrecovered processing locks by owner run", () => { assert.equal(result.recovered.length, 0); assert.deepEqual( - result.remaining.map((entry) => [entry.filePath, entry.lockedByRunId]), + result.remaining.map((entry) => [entry.filePath, entry.lockedByRunId]).sort(), [ ["app/One.kt", "run-active"], ["app/Two.kt", "run-active"], @@ -101,6 +101,28 @@ test("reports multiple unrecovered processing locks by owner run", () => { ); }); +test("dry run reports recoverable locks without treating them as active", () => { + const fixture = createFixture(); + writeRun(fixture, "run-done", { phase: "done" }); + writeRecord(fixture, "app/Done.kt", { + status: "processing", + lockedByRunId: "run-done", + lockedAt: "2026-05-14T07:00:00.000Z", + }); + + const result = recoverProcessingLocks({ + dataDir: fixture.dataDir, + projectId: fixture.projectId, + dryRun: true, + now: new Date("2026-05-14T07:30:00.000Z"), + }); + + assert.deepEqual(result.recovered.map((entry) => entry.reason), ["owner-done"]); + assert.deepEqual(result.remaining, []); + assert.equal(readRecord(fixture, "app/Done.kt").status, "processing"); + assert.equal(readRun(fixture, "run-done").phase, "done"); +}); + test("cli accepts pnpm argument separator before flags", () => { const fixture = createFixture(); const scriptPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "recover-processing-locks.mjs"); @@ -122,6 +144,15 @@ test("cli accepts pnpm argument separator before flags", () => { assert.match(output, /Would recover 0 processing file lock\(s\)\./); }); +for (const option of ["--data-dir", "--project-id", "--stale-minutes", "--force-run-id"]) { + test(`cli reports a missing value for ${option}`, () => { + const scriptPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "recover-processing-locks.mjs"); + const result = spawnSync(process.execPath, [scriptPath, option], { encoding: "utf8" }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, new RegExp(`Missing value for ${option}`)); + }); +} function createFixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-locks-")); const projectId = "dbcheck"; From 5e57dda817049a1e85b5fe595cb6acac6fb1c8d6 Mon Sep 17 00:00:00 2001 From: Emma Date: Sun, 26 Jul 2026 06:11:17 +0300 Subject: [PATCH 2/2] Korjaa CodeRabbitin matcher-havainnot --- .../android-uri-share-without-clipdata.ts | 54 +++++--- .deepsec/matchers/security-matchers.test.ts | 79 ++++++++++- .deepsec/matchers/sensitive-android-log.ts | 34 +---- .deepsec/matchers/utils.ts | 127 ++++++++++++++++++ .../scripts/recover-processing-locks.test.mjs | 9 ++ 5 files changed, 250 insertions(+), 53 deletions(-) diff --git a/.deepsec/matchers/android-uri-share-without-clipdata.ts b/.deepsec/matchers/android-uri-share-without-clipdata.ts index 55b0487..c430863 100644 --- a/.deepsec/matchers/android-uri-share-without-clipdata.ts +++ b/.deepsec/matchers/android-uri-share-without-clipdata.ts @@ -1,5 +1,11 @@ import type { CandidateMatch, MatcherPlugin } from "deepsec/config"; -import { candidate, isTestFile } from "./utils.js"; +import { + candidate, + findBalancedDelimiterEnd, + findCallOpeningParenthesis, + findEnclosingBlockEnds, + isTestFile, +} from "./utils.js"; const sendAction = /\bIntent\.ACTION_SEND(?:_MULTIPLE)?\b/g; @@ -37,32 +43,40 @@ function shareScopes(content: string): Array<{ start: number; text: string }> { const starts = [...content.matchAll(sendAction)].map((match) => match.index ?? 0); return starts.map((start, index) => { const nextShareStart = starts[index + 1] ?? content.length; - const enclosingBlockEnd = findEnclosingBlockEnd(content, start); - const end = Math.min(nextShareStart, enclosingBlockEnd); + const end = findShareScopeEnd(content, start, nextShareStart); return { start, text: content.slice(start, end) }; }); } -function findEnclosingBlockEnd(content: string, index: number): number { - const openBraces: number[] = []; - for (let cursor = 0; cursor < index; cursor += 1) { - if (content[cursor] === "{") { - openBraces.push(cursor); - } else if (content[cursor] === "}") { - openBraces.pop(); +function findShareScopeEnd(content: string, start: number, nextShareStart: number): number { + const builderEnd = findIntentBuilderEnd(content, start); + if (builderEnd !== null) { + const boundedBuilderEnd = Math.min(nextShareStart, builderEnd + 1); + if (content.slice(start, boundedBuilderEnd).includes("Intent.EXTRA_STREAM")) { + return boundedBuilderEnd; } } - if (openBraces.length === 0) return content.length; - - let depth = 1; - for (let cursor = index; cursor < content.length; cursor += 1) { - if (content[cursor] === "{") { - depth += 1; - } else if (content[cursor] === "}") { - depth -= 1; - if (depth === 0) return cursor; + for (const enclosingBlockEnd of findEnclosingBlockEnds(content, start)) { + const boundedBlockEnd = Math.min(nextShareStart, enclosingBlockEnd + 1); + if (content.slice(start, boundedBlockEnd).includes("Intent.EXTRA_STREAM")) { + return boundedBlockEnd; } } - return content.length; + + return nextShareStart; +} + +function findIntentBuilderEnd(content: string, actionStart: number): number | null { + const openParenthesis = findCallOpeningParenthesis(content, "Intent", actionStart); + if (openParenthesis === null) return null; + + const closeParenthesis = findBalancedDelimiterEnd(content, openParenthesis, "(", ")"); + if (closeParenthesis === null) return null; + + const builderMatch = /^\s*\.(?:apply|also|let|run)\s*\{/.exec(content.slice(closeParenthesis + 1)); + if (builderMatch === null) return null; + + const openBrace = closeParenthesis + 1 + builderMatch[0].lastIndexOf("{"); + return findBalancedDelimiterEnd(content, openBrace, "{", "}"); } diff --git a/.deepsec/matchers/security-matchers.test.ts b/.deepsec/matchers/security-matchers.test.ts index 892c541..b88929c 100644 --- a/.deepsec/matchers/security-matchers.test.ts +++ b/.deepsec/matchers/security-matchers.test.ts @@ -36,13 +36,29 @@ test("sensitive log matcher covers multiline calls", () => { test("sensitive log matcher does not consume a later Kotlin statement", () => { const matches = sensitiveAndroidLog.match( `Log.i(TAG, "sync complete") -writeNoiseDose(report)`, +val exportedUri = uri`, "app/src/main/java/com/dbcheck/app/HealthSync.kt", ); assert.deepEqual(matches, []); }); +test("sensitive log matcher ignores closing parentheses inside Kotlin comments", () => { + const matches = sensitiveAndroidLog.match( + `Log.i( + TAG, // ) + "Exported session URI: $uri", + ) +Log.w( + TAG, /* ) */ + "Backup file URI: $uri", +)`, + "app/src/main/java/com/dbcheck/app/ExportLogger.kt", + ); + + assert.equal(matches.length, 2); +}); + test("foreground matcher covers direct and ServiceCompat promotion", () => { const direct = foregroundAudioServiceStart.match( "class MeasurementForegroundService { fun promote() = startForeground(ID, notification) }", @@ -114,3 +130,64 @@ test("URI share matcher reports a missing ClipData in an otherwise granted scope assert.equal(matches.length, 1); assert.equal(matches[0]?.matchedPattern, "EXTRA_STREAM content URI share without ClipData"); }); + +test("URI share matcher bounds an unsafe share to its builder block", () => { + const matches = androidUriShareWithoutClipData.match( + `fun share(uri: Uri): Intent { + val intent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + val unrelatedClipData = ClipData.newPlainText("preview", "text") + val unrelatedFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION + return intent + }`, + "app/src/main/java/com/dbcheck/app/ShareFactory.kt", + ); + + assert.equal(matches.length, 1); + assert.equal( + matches[0]?.matchedPattern, + "EXTRA_STREAM content URI share without FLAG_GRANT_READ_URI_PERMISSION", + ); +}); + +test("URI share matcher continues from a nested constructor to its owning function", () => { + const matches = androidUriShareWithoutClipData.match( + `fun share(uri: Uri): Intent { + val intent = run { + Intent(Intent.ACTION_SEND) + } + intent.putExtra(Intent.EXTRA_STREAM, uri) + return intent + }`, + "app/src/main/java/com/dbcheck/app/ShareFactory.kt", + ); + + assert.equal(matches.length, 1); + assert.equal( + matches[0]?.matchedPattern, + "EXTRA_STREAM content URI share without FLAG_GRANT_READ_URI_PERMISSION", + ); +}); + +test("URI share matcher recognizes constructor trivia before the opening parenthesis", () => { + const matches = androidUriShareWithoutClipData.match( + `fun share(uri: Uri): Intent { + val intent = Intent /* share constructor */ ( + Intent.ACTION_SEND + ).apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + val unrelatedClipData = ClipData.newPlainText("preview", "text") + val unrelatedFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION + return intent + }`, + "app/src/main/java/com/dbcheck/app/ShareFactory.kt", + ); + + assert.equal(matches.length, 1); + assert.equal( + matches[0]?.matchedPattern, + "EXTRA_STREAM content URI share without FLAG_GRANT_READ_URI_PERMISSION", + ); +}); diff --git a/.deepsec/matchers/sensitive-android-log.ts b/.deepsec/matchers/sensitive-android-log.ts index f1c091a..cd48368 100644 --- a/.deepsec/matchers/sensitive-android-log.ts +++ b/.deepsec/matchers/sensitive-android-log.ts @@ -1,5 +1,5 @@ import type { CandidateMatch, MatcherPlugin } from "deepsec/config"; -import { candidate, isTestFile } from "./utils.js"; +import { candidate, findBalancedDelimiterEnd, isTestFile } from "./utils.js"; const sensitiveWords = "(?:session|sessionId|measurement|decibel|dbWeighted|dbcheck\\.db|audio|microphone|recording|sample|billing|purchase|token|backup|restore|health|heart|threshold|hearing|csv|pdf|export|uri|fileprovider)"; @@ -18,40 +18,10 @@ export const sensitiveAndroidLog: MatcherPlugin = { return [...content.matchAll(logCallStart)].flatMap((match) => { const start = match.index ?? 0; const openParenthesis = start + match[0].lastIndexOf("("); - const end = findCallEnd(content, openParenthesis); + const end = findBalancedDelimiterEnd(content, openParenthesis, "(", ")"); if (end === null || !sensitiveWordPattern.test(content.slice(openParenthesis + 1, end))) return []; return candidate("sensitive-android-log", content, start, "Sensitive term in Android log call"); }); }, }; - -function findCallEnd(content: string, openParenthesis: number): number | null { - let depth = 0; - let quote: "'" | '"' | null = null; - let escaped = false; - - for (let cursor = openParenthesis; cursor < content.length; cursor += 1) { - const character = content[cursor]; - if (quote !== null) { - if (escaped) { - escaped = false; - } else if (character === "\\") { - escaped = true; - } else if (character === quote) { - quote = null; - } - continue; - } - - if (character === "'" || character === '"') { - quote = character; - } else if (character === "(") { - depth += 1; - } else if (character === ")") { - depth -= 1; - if (depth === 0) return cursor; - } - } - return null; -} diff --git a/.deepsec/matchers/utils.ts b/.deepsec/matchers/utils.ts index c85d765..0d0ecf7 100644 --- a/.deepsec/matchers/utils.ts +++ b/.deepsec/matchers/utils.ts @@ -4,6 +4,67 @@ export function isTestFile(filePath: string): boolean { return /(?:^|[\\/])(?:test|androidTest|__tests__)(?:[\\/])|[._-](?:test|spec)\./i.test(filePath); } +export function findBalancedDelimiterEnd( + content: string, + openIndex: number, + opening: "(" | "{", + closing: ")" | "}", +): number | null { + if (content[openIndex] !== opening) return null; + + let depth = 0; + for (const cursor of codeCharacterIndices(content, openIndex, content.length)) { + if (content[cursor] === opening) { + depth += 1; + } else if (content[cursor] === closing) { + depth -= 1; + if (depth === 0) return cursor; + } + } + return null; +} + +export function findEnclosingBlockEnds(content: string, index: number): number[] { + const openBraces: number[] = []; + for (const cursor of codeCharacterIndices(content, 0, index)) { + if (content[cursor] === "{") { + openBraces.push(cursor); + } else if (content[cursor] === "}") { + openBraces.pop(); + } + } + + return openBraces + .reverse() + .map((openBrace) => findBalancedDelimiterEnd(content, openBrace, "{", "}")) + .filter((end): end is number => end !== null); +} + +export function findCallOpeningParenthesis( + content: string, + identifier: string, + containingIndex: number, +): number | null { + const escapedIdentifier = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const identifierPattern = new RegExp(`\\b${escapedIdentifier}\\b`, "g"); + const codeIndices = new Set(codeCharacterIndices(content, 0, containingIndex + 1)); + const matches = [...content.slice(0, containingIndex + 1).matchAll(identifierPattern)]; + + for (const match of matches.reverse()) { + const identifierStart = match.index ?? 0; + if (!codeIndices.has(identifierStart)) continue; + + const identifierEnd = identifierStart + match[0].length; + const openParenthesis = firstNonWhitespaceCodeIndex(content, identifierEnd, containingIndex + 1); + if (openParenthesis === null || content[openParenthesis] !== "(") continue; + + const closeParenthesis = findBalancedDelimiterEnd(content, openParenthesis, "(", ")"); + if (closeParenthesis !== null && closeParenthesis >= containingIndex) return openParenthesis; + } + + return null; +} + export function lineNumberAt(content: string, index: number): number { return content.slice(0, index).split(/\r\n|\r|\n/).length; } @@ -47,3 +108,69 @@ export function regexCandidates( return matches; } + +function* codeCharacterIndices(content: string, start: number, end: number): Generator { + let quote: "'" | '"' | '"""' | null = null; + let escaped = false; + let lineComment = false; + let blockCommentDepth = 0; + + for (let cursor = start; cursor < end; cursor += 1) { + if (lineComment) { + if (content[cursor] === "\n" || content[cursor] === "\r") lineComment = false; + continue; + } + + if (blockCommentDepth > 0) { + if (content.startsWith("/*", cursor)) { + blockCommentDepth += 1; + cursor += 1; + } else if (content.startsWith("*/", cursor)) { + blockCommentDepth -= 1; + cursor += 1; + } + continue; + } + + if (quote === '"""') { + if (content.startsWith('"""', cursor)) { + quote = null; + cursor += 2; + } + continue; + } + + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (content[cursor] === "\\") { + escaped = true; + } else if (content[cursor] === quote) { + quote = null; + } + continue; + } + + if (content.startsWith("//", cursor)) { + lineComment = true; + cursor += 1; + } else if (content.startsWith("/*", cursor)) { + blockCommentDepth = 1; + cursor += 1; + } else if (content.startsWith('"""', cursor)) { + quote = '"""'; + cursor += 2; + } else if (content[cursor] === "'" || content[cursor] === '"') { + quote = content[cursor] as "'" | '"'; + } else { + yield cursor; + } + } +} + +function firstNonWhitespaceCodeIndex(content: string, start: number, end: number): number | null { + for (const cursor of codeCharacterIndices(content, start, end)) { + if (!/\s/.test(content[cursor] ?? "")) return cursor; + } + return null; +} diff --git a/.deepsec/scripts/recover-processing-locks.test.mjs b/.deepsec/scripts/recover-processing-locks.test.mjs index 3ab08cb..b1a954f 100644 --- a/.deepsec/scripts/recover-processing-locks.test.mjs +++ b/.deepsec/scripts/recover-processing-locks.test.mjs @@ -151,6 +151,15 @@ for (const option of ["--data-dir", "--project-id", "--stale-minutes", "--force- assert.notEqual(result.status, 0); assert.match(result.stderr, new RegExp(`Missing value for ${option}`)); + + const optionLikeValue = spawnSync( + process.execPath, + [scriptPath, option, "--dry-run"], + { encoding: "utf8" }, + ); + + assert.notEqual(optionLikeValue.status, 0); + assert.match(optionLikeValue.stderr, new RegExp(`Missing value for ${option}`)); }); } function createFixture() {