From 91c9a4680bba26c66f780c007acd76b22184b95e Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Tue, 11 Aug 2026 08:28:46 -0400 Subject: [PATCH 1/2] fix: isolate Sheets caches by credential scope --- Code.gs | 179 ++++++++++++++++------ DEPLOYMENT_GUIDE.md | 23 +-- MARKETPLACE_LISTING.md | 6 +- OAUTH_VERIFICATION.md | 3 +- README.md | 8 +- docs/index.html | 2 +- package-lock.json | 4 +- package.json | 6 +- scripts/scan-secrets.js | 71 +++++++++ scripts/scan-secrets.sh | 31 +--- test/public-claims.test.js | 15 +- test/runtime.test.js | 301 +++++++++++++++++++++++++++++++++++-- test/secret-scan.test.js | 43 ++++++ test/validate_code.js | 2 + 14 files changed, 585 insertions(+), 109 deletions(-) create mode 100644 scripts/scan-secrets.js create mode 100644 test/secret-scan.test.js diff --git a/Code.gs b/Code.gs index b8eb4c4..5c67ddd 100644 --- a/Code.gs +++ b/Code.gs @@ -6,7 +6,7 @@ */ const API_BASE_URL = 'https://api.oilpriceapi.com/v1'; -const ADDON_VERSION = '1.3.0'; +const ADDON_VERSION = '1.3.1'; const KEY_PROPERTY = 'OILPRICEAPI_KEY'; const LAST_DIAGNOSTIC_PROPERTY = 'OILPRICEAPI_LAST_DIAGNOSTIC'; const CACHE_GENERATION_PROPERTY = 'OILPRICEAPI_CACHE_GENERATION'; @@ -28,6 +28,7 @@ const CACHE_TTL_SECONDS = { }; const MAX_CACHE_TTL_SECONDS = 21600; let cachedGeneration_ = null; +let cachedCacheScope_ = null; // Keep generic worksheet requests aligned with the Excel add-in's reviewed // endpoint catalog. Add endpoints deliberately after API-shape tests exist. @@ -193,10 +194,7 @@ function getApiKey_() { const spreadsheetKey = spreadsheetKeyProperty ? userProperties.getProperty(spreadsheetKeyProperty) : null; - if (spreadsheetKey) return spreadsheetKey; - - // Migration fallback for keys saved by releases before Apps Script version 6. - return userProperties.getProperty(KEY_PROPERTY); + return spreadsheetKey || null; } function requireApiKey_() { @@ -236,6 +234,7 @@ function saveApiKey(apiKey) { userProperties.setProperty(`${CACHE_GENERATION_PROPERTY}:${getActiveSpreadsheetId_()}`, cacheGeneration); userProperties.deleteProperty(KEY_PROPERTY); cachedGeneration_ = null; + cachedCacheScope_ = null; return { success: true, message: 'API key saved for this spreadsheet in Apps Script properties.' @@ -257,6 +256,7 @@ function deleteApiKey() { userProperties.deleteProperty(KEY_PROPERTY); userProperties.deleteProperty(LAST_DIAGNOSTIC_PROPERTY); cachedGeneration_ = null; + cachedCacheScope_ = null; return { success: true, message: 'Stored spreadsheet API key and request diagnostic deleted.' @@ -312,38 +312,107 @@ function responseHeader_(response, name) { return key && typeof headers[key] === 'string' ? headers[key].slice(0, 128) : ''; } -function cacheGeneration_() { - if (cachedGeneration_ !== null) return cachedGeneration_; +function cacheContext_() { + if (cachedGeneration_ !== null && cachedCacheScope_ !== null) { + return { generation: cachedGeneration_, scope: cachedCacheScope_ }; + } const documentProperties = getDocumentProperties_(); + const documentKey = documentProperties + ? documentProperties.getProperty(KEY_PROPERTY) + : null; const documentGeneration = documentProperties ? documentProperties.getProperty(CACHE_GENERATION_PROPERTY) : null; - if (documentGeneration) { + if (documentKey && documentGeneration && documentGeneration !== 'legacy') { cachedGeneration_ = documentGeneration; - return cachedGeneration_; + cachedCacheScope_ = 'document'; + return { generation: cachedGeneration_, scope: cachedCacheScope_ }; } const spreadsheetId = getActiveSpreadsheetId_(); - if (!spreadsheetId) { - cachedGeneration_ = 'legacy'; - return cachedGeneration_; + let userGeneration = null; + if (spreadsheetId) { + try { + userGeneration = PropertiesService.getUserProperties().getProperty( + `${CACHE_GENERATION_PROPERTY}:${spreadsheetId}` + ); + } catch (error) { + // Unknown credential contexts must remain isolated in the user cache. + } + } + cachedGeneration_ = userGeneration || 'legacy'; + cachedCacheScope_ = 'user'; + return { generation: cachedGeneration_, scope: cachedCacheScope_ }; +} + +function cacheGeneration_() { + return cacheContext_().generation; +} + +function effectiveCacheScope_(requestedScope) { + if (requestedScope === 'user') return 'user'; + return cacheContext_().scope; +} + +function nextCacheGeneration_(previousGeneration) { + const previous = Number(previousGeneration); + return String( + Math.max(Date.now(), Number.isFinite(previous) ? previous + 1 : 0) + ); +} + +function invalidateCacheGeneration_() { + const currentContext = cacheContext_(); + const generation = nextCacheGeneration_(currentContext.generation); + let activeScopeInvalidated = false; + if (currentContext.scope === 'document') { + const documentProperties = getDocumentProperties_(); + if (documentProperties) { + try { + documentProperties.setProperty(CACHE_GENERATION_PROPERTY, generation); + activeScopeInvalidated = true; + } catch (error) { + // A fallback generation cannot invalidate an active document cache. + } + } + } + const spreadsheetId = getActiveSpreadsheetId_(); + if (spreadsheetId) { + try { + PropertiesService.getUserProperties().setProperty( + `${CACHE_GENERATION_PROPERTY}:${spreadsheetId}`, + generation + ); + if (currentContext.scope === 'user') activeScopeInvalidated = true; + } catch (error) { + // A confirmed document write remains sufficient for document scope. + } + } + cachedGeneration_ = null; + cachedCacheScope_ = null; + if (!activeScopeInvalidated) { + throw makeError_( + 'RETRY_LATER', + 'Connection succeeded, but cached worksheet state could not be refreshed. Run Test Connection again.' + ); } - cachedGeneration_ = PropertiesService.getUserProperties().getProperty( - `${CACHE_GENERATION_PROPERTY}:${spreadsheetId}` - ) || 'legacy'; - return cachedGeneration_; } function stableCacheHash_(value) { + const text = String(value || ''); + const digest = stableCacheDigest_(text); + const slug = text.replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 96); + return `${slug}_${digest}`; +} + +function stableCacheDigest_(value) { let hash = 2166136261; const text = String(value || ''); for (let index = 0; index < text.length; index += 1) { hash ^= text.charCodeAt(index); hash = Math.imul(hash, 16777619); } - const digest = (hash >>> 0).toString(36); - const slug = text.replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 96); - return `${slug}_${digest}`; + return (hash >>> 0).toString(36); } function requestBlockKeys_(path) { @@ -686,35 +755,47 @@ function validatePriceRecord_(record, subject) { }; } -function cacheStore_(scope) { +function cacheStoreContext_(scope) { if (typeof CacheService === 'undefined') return null; - if (scope === 'document' && typeof CacheService.getDocumentCache === 'function') { + const effectiveScope = effectiveCacheScope_(scope || 'document'); + if ( + effectiveScope === 'document' && + typeof CacheService.getDocumentCache === 'function' + ) { try { const documentCache = CacheService.getDocumentCache(); - if (documentCache) return documentCache; + if (documentCache) return { store: documentCache, scope: 'document' }; } catch (error) { // Fall through to the per-user cache when the document cache is unavailable. } } try { - return typeof CacheService.getUserCache === 'function' - ? CacheService.getUserCache() - : null; + if (typeof CacheService.getUserCache !== 'function') return null; + const userCache = CacheService.getUserCache(); + return userCache ? { store: userCache, scope: 'user' } : null; } catch (error) { return null; } } -function namespacedCacheKey_(cacheKey) { - return `opa_${cacheGeneration_()}_${cacheKey}`; +function namespacedCacheKey_(cacheKey, cacheScope) { + if (cacheScope === 'user') { + const spreadsheetId = getActiveSpreadsheetId_(); + if (!spreadsheetId) return null; + const spreadsheetHash = + `${stableCacheDigest_(spreadsheetId)}${stableCacheDigest_(`sheet:${spreadsheetId}`)}`; + return `opa_u_${spreadsheetHash}_${cacheGeneration_()}_${cacheKey}`; + } + return `opa_d_${cacheGeneration_()}_${cacheKey}`; } function getCachedValue_(cacheKey, maxAgeSeconds, scope) { try { - const cache = cacheStore_(scope || 'document'); - if (!cache || typeof cache.get !== 'function') return null; - const namespacedKey = namespacedCacheKey_(cacheKey); - const raw = cache.get(namespacedKey); + const cacheContext = cacheStoreContext_(scope || 'document'); + if (!cacheContext || typeof cacheContext.store.get !== 'function') return null; + const namespacedKey = namespacedCacheKey_(cacheKey, cacheContext.scope); + if (!namespacedKey) return null; + const raw = cacheContext.store.get(namespacedKey); if (!raw) return null; let envelope; @@ -722,7 +803,7 @@ function getCachedValue_(cacheKey, maxAgeSeconds, scope) { envelope = JSON.parse(raw); } catch (error) { try { - cache.remove(namespacedKey); + cacheContext.store.remove(namespacedKey); } catch (removeError) { // An invalid entry can expire naturally if removal is unavailable. } @@ -735,7 +816,7 @@ function getCachedValue_(cacheKey, maxAgeSeconds, scope) { Date.now() - envelope.cachedAt > maxAgeSeconds * 1000 ) { try { - cache.remove(namespacedKey); + cacheContext.store.remove(namespacedKey); } catch (removeError) { // An expired entry can expire naturally if removal is unavailable. } @@ -750,8 +831,12 @@ function getCachedValue_(cacheKey, maxAgeSeconds, scope) { function putCachedValue_(cacheKey, value, ttlSeconds, scope) { try { - cacheStore_(scope || 'document').put( - namespacedCacheKey_(cacheKey), + const cacheContext = cacheStoreContext_(scope || 'document'); + if (!cacheContext || typeof cacheContext.store.put !== 'function') return; + const namespacedKey = namespacedCacheKey_(cacheKey, cacheContext.scope); + if (!namespacedKey) return; + cacheContext.store.put( + namespacedKey, JSON.stringify({ cachedAt: Date.now(), value }), Math.min(MAX_CACHE_TTL_SECONDS, Math.max(1, Math.round(ttlSeconds))) ); @@ -762,9 +847,10 @@ function putCachedValue_(cacheKey, value, ttlSeconds, scope) { function removeCachedValue_(cacheKey, scope) { try { - const cache = cacheStore_(scope || 'document'); - if (cache && typeof cache.remove === 'function') { - cache.remove(namespacedCacheKey_(cacheKey)); + const cacheContext = cacheStoreContext_(scope || 'document'); + if (cacheContext && typeof cacheContext.store.remove === 'function') { + const namespacedKey = namespacedCacheKey_(cacheKey, cacheContext.scope); + if (namespacedKey) cacheContext.store.remove(namespacedKey); } } catch (error) { // Request-block cleanup must not replace a valid live response. @@ -963,6 +1049,7 @@ function testConnection() { { bypassBlock: true } ); validatePriceRecord_(extractPriceRecords_(body, 'price')[0], 'Price record'); + invalidateCacheGeneration_(); return { success: true, message: 'Connection and response schema verified.' }; } catch (error) { return { success: false, message: error.message }; @@ -971,20 +1058,20 @@ function testConnection() { function getUserInfo() { if (!getApiKey_()) { - return { tier: 'none', limit: null, used: null }; + return { tier: 'none', limit: null, used: null, window: null }; } try { const body = requestJson_('/users/me', getApiKey_()); const data = body.data && typeof body.data === 'object' ? body.data : body; const tier = typeof data.tier === 'string' ? data.tier : (typeof data.plan === 'string' ? data.plan : null); - const limit = Number(data.request_limit); - const used = Number(data.requests_this_month); - if (!tier || !Number.isFinite(limit) || !Number.isFinite(used)) { - return { tier: 'unknown', limit: null, used: null }; - } - return { tier, limit, used }; + return { + tier: tier || 'unknown', + limit: null, + used: null, + window: null + }; } catch (error) { - return { tier: 'unknown', limit: null, used: null, message: error.message }; + return { tier: 'unknown', limit: null, used: null, window: null, message: error.message }; } } diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index 0730edb..a601c67 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -7,21 +7,21 @@ test installation, screenshots, and submission require the publisher account. ## Current release gate - Public runtime version: `1.2.2` -- Repository release candidate: `1.3.0` +- Repository release candidate: `1.3.1` - Production Apps Script ID: `1rlVWvciYu-wzqnY009I3oW-08ZPazYK1snrrMg9NNY7c5WBSkUK8W2Hb` -- Current immutable Apps Script version: `11` (runtime `1.2.2`, cut - 2026-07-29 18:01 EDT / 22:01 UTC, immediately after PR #22 merged). - Verified 2026-07-31 in Apps Script Project History; version 11's `Code.gs` - reads `ADDON_VERSION = '1.2.2'`. +- Latest immutable Apps Script version: `12` (runtime `1.3.0`, created by + workflow run `31489545156` on 2026-08-11). Version 12 failed the subsequent + user-scoped cache-isolation review and was never published. The Marketplace + remains on version `11` (runtime `1.2.2`). - GitHub production release workflow: `https://github.com/OilpriceAPI/google-sheets-addin/actions/workflows/apps-script-release.yml` - Note: versions 9-11 were cut locally with `npm run deploy:version`, not by - this workflow, whose only run to date is 2026-07-24. + Note: versions 9-11 were cut locally with `npm run deploy:version`; version + 12 was cut by the governed workflow above. - Marketplace status: **published** at `https://workspace.google.com/marketplace/app/oilpriceapi_for_google_sheets/991152473434`. The public listing points to immutable Apps Script version 11 (`1.2.2`). - Do not select the `1.3.0` candidate until its exact immutable version passes + Do not select the `1.3.1` candidate until its exact immutable version passes an installed Marketplace formula smoke. - Runtime push/version and local deployment checks: complete - Marketplace review receipt and a real 1280x800 screenshot: complete @@ -191,7 +191,7 @@ screenshots until they have been reviewed for secrets and customer data. After the smoke passes against the exact pushed source: ```bash -npm run deploy:version -- "OilPriceAPI for Google Sheets 1.3.0 customer-path recovery" +npm run deploy:version -- "OilPriceAPI for Google Sheets 1.3.1 cache-isolation recovery" npm run deploy:list ``` @@ -272,5 +272,10 @@ Push and smoke-test the new code, create a new Apps Script version, then update the version number on the Marketplace SDK App Configuration page. Do not create a new app integration or change the Script ID. +Runtime `1.3.1` retires unscoped API keys saved by releases before Apps Script +version 6. Those legacy values cannot be attributed to a particular spreadsheet +and are therefore not safe to use. Affected users must open each intended +spreadsheet and save the key again from **OilPriceAPI > Configure API Key**. + If scopes change, update all three scope lists and complete any required OAuth reverification before publishing the new version. diff --git a/MARKETPLACE_LISTING.md b/MARKETPLACE_LISTING.md index 7799f32..aa205b2 100644 --- a/MARKETPLACE_LISTING.md +++ b/MARKETPLACE_LISTING.md @@ -4,7 +4,7 @@ Status: **publicly available** at `https://workspace.google.com/marketplace/app/oilpriceapi_for_google_sheets/991152473434`. Public Marketplace Apps Script version: `11` (runtime `1.2.2`). Runtime -`1.3.0` remains a release candidate until an immutable Apps Script version is +`1.3.1` remains a release candidate until an immutable Apps Script version is cut, smoke-tested through an installed Marketplace add-on, and selected in App Configuration. @@ -79,8 +79,10 @@ identity defaults for product behavior and does not request Drive-wide access. - Public listing: `https://workspace.google.com/marketplace/app/oilpriceapi_for_google_sheets/991152473434` - Public Marketplace Apps Script version: `11` -- Latest immutable Apps Script version: `11` +- Latest immutable Apps Script version: `12` - Runtime release represented by version 11: `1.2.2` +- Version 12 represented runtime `1.3.0`, failed the user-scoped cache-isolation + review, and was never published. Runtime `1.3.1` is its replacement candidate. - Superseded: version 10 (`1.2.1`), cut before the PR #22 credential fix - Integration: Google Sheets Editor add-on - Install modes: individual and administrator diff --git a/OAUTH_VERIFICATION.md b/OAUTH_VERIFICATION.md index 29414db..b178e8d 100644 --- a/OAUTH_VERIFICATION.md +++ b/OAUTH_VERIFICATION.md @@ -29,7 +29,8 @@ versions 9 and 10 are superseded. Private Cloud-console fields are not inferred from public availability. The customer release gate is the public listing plus an installed-add-on formula -smoke. Runtime `1.3.0` must therefore remain a release candidate until it is +smoke. Immutable version 12 (runtime `1.3.0`) failed cache-isolation review and +was never published. Runtime `1.3.1` must therefore remain a release candidate until it is merged, pushed, cut as a new immutable Apps Script version, installed through Marketplace, and smoke-tested before App Configuration is updated. diff --git a/README.md b/README.md index 30c156c..11b2a9d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Sheets™. The add-on is [publicly available in Google Workspace Marketplace](https://workspace.google.com/marketplace/app/oilpriceapi_for_google_sheets/991152473434). The public listing currently points to immutable Apps Script version 11 -(`1.2.2`). Runtime `1.3.0` is a release candidate until its installed-add-on +(`1.2.2`). Runtime `1.3.1` is a release candidate until its installed-add-on smoke and Marketplace version update are recorded. Dataset access, history, freshness, and limits depend on the API key, source, @@ -60,6 +60,10 @@ The original `OILPRICE(code)` formula remains supported for existing sheets. the key without making it available to another spreadsheet. The spreadsheet owner should configure the key. Editors of that spreadsheet can cause add-on formulas to make requests with the configured key. +- Unscoped keys saved by releases before Apps Script version 6 are deliberately + not read because they cannot be tied to one spreadsheet. After upgrading from + such a release, open each intended spreadsheet and save the key again from + **OilPriceAPI > Configure API Key**. - The sidebar receives only configured/not-configured state; it never reads the stored key into browser-side HTML. - Generic GET calls are restricted to the same reviewed endpoint catalog as @@ -122,7 +126,7 @@ npm run clasp:login read -r "OPA_SCRIPT_ID?Apps Script ID: " npm run clasp:configure -- "$OPA_SCRIPT_ID" npm run deploy:push -npm run deploy:version -- "OilPriceAPI for Google Sheets 1.3.0 customer-path recovery" +npm run deploy:version -- "OilPriceAPI for Google Sheets 1.3.1 cache-isolation recovery" ``` Editor add-on publication uses the Apps Script **script ID and version diff --git a/docs/index.html b/docs/index.html index 2ae8e05..b06831d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -82,7 +82,7 @@

Publication status

Available in Google Workspace Marketplace. The public listing points to immutable Apps Script version 11 (runtime - 1.2.2). Runtime 1.3.0 remains a release candidate until its replacement + 1.2.2). Runtime 1.3.1 remains a release candidate until its replacement version passes installed-add-on smoke testing and is selected in Marketplace App Configuration.

diff --git a/package-lock.json b/package-lock.json index 8013745..c6ae69b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oilpriceapi-google-sheets-addin", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oilpriceapi-google-sheets-addin", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "devDependencies": { "@google/clasp": "3.3.0", diff --git a/package.json b/package.json index f2db6fb..720fb5c 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "oilpriceapi-google-sheets-addin", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "Google Sheets Editor add-on for source-aware OilPriceAPI formulas", "license": "MIT", "scripts": { - "test": "node --test test/runtime.test.js test/public-claims.test.js", + "test": "node --test test/runtime.test.js test/public-claims.test.js test/secret-scan.test.js", "test:portfolio": "node --test test/portfolio.test.js", "test:live": "node test/live-smoke.js", "portfolio:build": "node scripts/build-portfolio.js", @@ -13,7 +13,7 @@ "assets": "node scripts/generate-marketplace-assets.js", "verify:assets": "node scripts/verify-marketplace-assets.js", "verify:deploy": "node scripts/verify-deploy-package.js", - "validate": "npm run portfolio:build && node test/validate_code.js && node --test test/runtime.test.js test/public-claims.test.js test/portfolio.test.js && node scripts/verify-deploy-package.js && node scripts/verify-marketplace-assets.js && node scripts/verify-portfolio.js && ./scripts/scan-secrets.sh", + "validate": "npm run portfolio:build && node test/validate_code.js && node --test test/runtime.test.js test/public-claims.test.js test/portfolio.test.js test/secret-scan.test.js && node scripts/verify-deploy-package.js && node scripts/verify-marketplace-assets.js && node scripts/verify-portfolio.js && ./scripts/scan-secrets.sh", "clasp:login": "clasp login", "clasp:configure": "node scripts/configure-clasp.js", "clasp:status": "clasp status", diff --git a/scripts/scan-secrets.js b/scripts/scan-secrets.js new file mode 100644 index 0000000..4d754e9 --- /dev/null +++ b/scripts/scan-secrets.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); + +const PATTERNS = [ + /[0-9a-fA-F]{64}/, + /sk-[A-Za-z0-9_-]{20,}/, + /AIza[A-Za-z0-9_-]{30,}/, + /gh[pousr]_[A-Za-z0-9_]{20,}/, + /(api[_-]?key|access[_-]?token|secret)[^\r\n]{0,40}[=:]\s*[A-Za-z0-9_-]{32,}/i, +]; + +const EXCLUDED_DIRECTORIES = new Set([".git", "node_modules"]); +const EXCLUDED_FILES = new Set([ + "package-lock.json", + "scripts/scan-secrets.js", + "scripts/scan-secrets.sh", + "test/secret-scan.test.js", +]); + +function normalizedRelativePath(root, filePath) { + return path.relative(root, filePath).split(path.sep).join("/"); +} + +function isLikelyBinary(buffer) { + return buffer.subarray(0, Math.min(buffer.length, 8192)).includes(0); +} + +function findPotentialSecretFiles(root) { + const resolvedRoot = path.resolve(root); + const findings = []; + + function visit(directory) { + const entries = fs.readdirSync(directory, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && EXCLUDED_DIRECTORIES.has(entry.name)) continue; + const filePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + visit(filePath); + continue; + } + if (!entry.isFile()) continue; + + const relativePath = normalizedRelativePath(resolvedRoot, filePath); + if (EXCLUDED_FILES.has(relativePath)) continue; + const contents = fs.readFileSync(filePath); + if (isLikelyBinary(contents)) continue; + const text = contents.toString("utf8"); + if (PATTERNS.some((pattern) => pattern.test(text))) findings.push(relativePath); + } + } + + visit(resolvedRoot); + return findings.sort(); +} + +function formatFindings(findings) { + if (findings.length === 0) return "Secret scan passed (filenames only).\n"; + return `Potential secret pattern found in:\n${findings.join("\n")}\n`; +} + +function main() { + const findings = findPotentialSecretFiles(process.argv[2] || process.cwd()); + process.stdout.write(formatFindings(findings)); + if (findings.length > 0) process.exitCode = 1; +} + +if (require.main === module) main(); + +module.exports = { findPotentialSecretFiles, formatFindings }; diff --git a/scripts/scan-secrets.sh b/scripts/scan-secrets.sh index 917c935..dccc4a0 100755 --- a/scripts/scan-secrets.sh +++ b/scripts/scan-secrets.sh @@ -1,33 +1,4 @@ #!/usr/bin/env bash set -euo pipefail -patterns=( - '[0-9a-fA-F]{64}' - 'sk-[A-Za-z0-9_-]{20,}' - 'AIza[A-Za-z0-9_-]{30,}' - 'gh[pousr]_[A-Za-z0-9_]{20,}' - '(?i)(api[_-]?key|access[_-]?token|secret)[^[:cntrl:]]{0,40}[=:][[:space:]]*[A-Za-z0-9_-]{32,}' -) - -found='' -for pattern in "${patterns[@]}"; do - matches="$( - rg -l --hidden \ - --glob '!.git/**' \ - --glob '!node_modules/**' \ - --glob '!scripts/scan-secrets.sh' \ - --glob '!package-lock.json' \ - "$pattern" . || true - )" - if [[ -n "$matches" ]]; then - found+="$matches"$'\n' - fi -done - -if [[ -n "$found" ]]; then - printf 'Potential secret pattern found in:\n' - printf '%s' "$found" | sort -u - exit 1 -fi - -printf 'Secret scan passed (filenames only).\n' +exec node "$(dirname "$0")/scan-secrets.js" "$@" diff --git a/test/public-claims.test.js b/test/public-claims.test.js index 62728ed..1988f31 100644 --- a/test/public-claims.test.js +++ b/test/public-claims.test.js @@ -60,11 +60,19 @@ test("operator records preserve the exact release and Google submission state", path.join(ROOT, "OAUTH_VERIFICATION.md"), "utf8", ); + const packageVersion = JSON.parse( + fs.readFileSync(path.join(ROOT, "package.json"), "utf8"), + ).version; + const runtime = fs.readFileSync(path.join(ROOT, "Code.gs"), "utf8"); const records = `${deployment}\n${listing}\n${oauth}`; + assert.equal(packageVersion, "1.3.1"); + assert.match(runtime, /ADDON_VERSION = '1\.3\.1'/); assert.match(deployment, /Public runtime version: `1\.2\.2`/); - assert.match(deployment, /Current immutable Apps Script version: `11`/); + assert.match(deployment, /Repository release candidate: `1\.3\.1`/); + assert.match(deployment, /Latest immutable Apps Script version: `12`/); assert.match(listing, /Public Marketplace Apps Script version: `11`/); + assert.match(records, /version 12[\s\S]{0,240}never published/i); assert.match(records, /publicly available/i); assert.match( records, @@ -108,6 +116,11 @@ test("sidebar gives an in-product privacy notice and policy links", () => { assert.match(sidebar, /Limited Use requirements/i); }); +test("legacy credential migration requires an explicit spreadsheet reconfigure", () => { + const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8"); + assert.match(readme, /unscoped keys[\s\S]{0,260}save the key again/i); +}); + test("public surfaces contain no unsupported mutable claims", () => { const text = PUBLIC_FILES.map((file) => fs.readFileSync(path.join(ROOT, file), "utf8"), diff --git a/test/runtime.test.js b/test/runtime.test.js index fadcf52..87eae20 100644 --- a/test/runtime.test.js +++ b/test/runtime.test.js @@ -43,11 +43,11 @@ function bunkerRecord(overrides = {}) { }; } -function createHarness() { - const documentPropertyValues = new Map(); - const userPropertyValues = new Map(); - const documentCache = new Map(); - let userCache = new Map(); +function createHarness(shared = {}) { + const documentPropertyValues = shared.documentPropertyValues || new Map(); + const userPropertyValues = shared.userPropertyValues || new Map(); + const documentCache = shared.documentCache || new Map(); + let userCache = shared.userCache || new Map(); const cachePuts = []; let lockAvailable = true; let lockHeld = false; @@ -325,6 +325,153 @@ test("spreadsheet-scoped owner fallback never crosses into another spreadsheet", ); }); +test("user-scoped fallback credentials never share cached responses", () => { + const shared = { + documentCache: new Map(), + documentPropertyValues: new Map(), + }; + const firstUser = createHarness(shared); + firstUser.context.PropertiesService.getDocumentProperties = () => null; + firstUser.userPropertyValues.set("OILPRICEAPI_KEY:sheet-a", "first-user-key"); + firstUser.queue(200, latestBody({ price: 81.78 })); + assert.equal(firstUser.context.OILPRICE_PRICE("WTI_USD"), 81.78); + + const secondUser = createHarness(shared); + secondUser.context.PropertiesService.getDocumentProperties = () => null; + secondUser.userPropertyValues.set("OILPRICEAPI_KEY:sheet-a", "second-user-key"); + secondUser.queue(200, latestBody({ price: 92.35 })); + assert.equal(secondUser.context.OILPRICE_PRICE("WTI_USD"), 92.35); + assert.equal(secondUser.requests.length, 1); + assert.equal( + secondUser.requests[0].options.headers.Authorization, + "Token second-user-key", + ); + assert.ok(firstUser.cachePuts.every((entry) => entry.scope === "user")); + assert.ok(secondUser.cachePuts.every((entry) => entry.scope === "user")); + assert.equal( + JSON.stringify([ + ...shared.documentCache.entries(), + ...firstUser.userCache.entries(), + ...secondUser.userCache.entries(), + ]).includes("user-key"), + false, + ); +}); + +test("user-scoped fallback credentials never share entitlement blocks", () => { + const shared = { + documentCache: new Map(), + documentPropertyValues: new Map(), + }; + const firstUser = createHarness(shared); + firstUser.context.PropertiesService.getDocumentProperties = () => null; + firstUser.userPropertyValues.set("OILPRICEAPI_KEY:sheet-a", "first-user-key"); + firstUser.queue(403, JSON.stringify({ error: "upgrade required" })); + assert.match( + firstUser.context.OILPRICE_HISTORY("WTI_USD", 30)[0][0], + /^#UPGRADE_REQUIRED$/, + ); + + const secondUser = createHarness(shared); + secondUser.context.PropertiesService.getDocumentProperties = () => null; + secondUser.userPropertyValues.set("OILPRICEAPI_KEY:sheet-a", "second-user-key"); + secondUser.queue( + 200, + historyBody([ + { + code: "WTI_USD", + price: 92.35, + currency: "USD", + unit: "barrel", + source: "market_reporting", + created_at: "2026-08-11T12:00:00.000Z", + }, + ]), + ); + assert.deepEqual( + JSON.parse( + JSON.stringify(secondUser.context.OILPRICE_HISTORY("WTI_USD", 30)), + ), + [["2026-08-11T12:00:00.000Z", 92.35]], + ); + assert.equal(secondUser.requests.length, 1); +}); + +test("one user's fallback response cache is isolated between spreadsheets", () => { + const shared = { + documentCache: new Map(), + documentPropertyValues: new Map(), + userCache: new Map(), + userPropertyValues: new Map([ + ["OILPRICEAPI_KEY:sheet-a", "sheet-a-key"], + ["OILPRICEAPI_KEY:sheet-b", "sheet-b-key"], + ]), + }; + const firstSheet = createHarness(shared); + firstSheet.context.PropertiesService.getDocumentProperties = () => null; + firstSheet.queue(200, latestBody({ price: 81.78 })); + assert.equal(firstSheet.context.OILPRICE_PRICE("WTI_USD"), 81.78); + + const secondSheet = createHarness(shared); + secondSheet.setActiveSpreadsheetId("sheet-b"); + secondSheet.context.PropertiesService.getDocumentProperties = () => null; + secondSheet.queue(200, latestBody({ price: 92.35 })); + assert.equal(secondSheet.context.OILPRICE_PRICE("WTI_USD"), 92.35); + assert.equal(secondSheet.requests.length, 1); + assert.equal( + secondSheet.requests[0].options.headers.Authorization, + "Token sheet-b-key", + ); + assert.ok( + [...shared.userCache.keys()].every( + (key) => !key.includes("sheet-a") && !key.includes("sheet-b"), + ), + ); +}); + +test("one user's fallback entitlement blocks are isolated between spreadsheets", () => { + const shared = { + documentCache: new Map(), + documentPropertyValues: new Map(), + userCache: new Map(), + userPropertyValues: new Map([ + ["OILPRICEAPI_KEY:sheet-a", "sheet-a-key"], + ["OILPRICEAPI_KEY:sheet-b", "sheet-b-key"], + ]), + }; + const firstSheet = createHarness(shared); + firstSheet.context.PropertiesService.getDocumentProperties = () => null; + firstSheet.queue(403, JSON.stringify({ error: "upgrade required" })); + assert.match( + firstSheet.context.OILPRICE_HISTORY("WTI_USD", 30)[0][0], + /^#UPGRADE_REQUIRED$/, + ); + + const secondSheet = createHarness(shared); + secondSheet.setActiveSpreadsheetId("sheet-b"); + secondSheet.context.PropertiesService.getDocumentProperties = () => null; + secondSheet.queue( + 200, + historyBody([ + { + code: "WTI_USD", + price: 92.35, + currency: "USD", + unit: "barrel", + source: "market_reporting", + created_at: "2026-08-11T12:00:00.000Z", + }, + ]), + ); + assert.deepEqual( + JSON.parse( + JSON.stringify(secondSheet.context.OILPRICE_HISTORY("WTI_USD", 30)), + ), + [["2026-08-11T12:00:00.000Z", 92.35]], + ); + assert.equal(secondSheet.requests.length, 1); +}); + test("deleting a key removes both document and spreadsheet-scoped owner stores", () => { const harness = createHarness(); harness.context.saveApiKey("test-key-not-a-secret"); @@ -337,16 +484,16 @@ test("deleting a key removes both document and spreadsheet-scoped owner stores", ); }); -test("legacy user-property key remains readable until it is saved per spreadsheet", () => { +test("an unscoped legacy user-property key cannot authorize another spreadsheet", () => { const harness = createHarness(); harness.userPropertyValues.set("OILPRICEAPI_KEY", "legacy-key"); - harness.queue(200, latestBody()); + harness.setActiveSpreadsheetId("sheet-b"); - assert.equal(harness.context.OILPRICE_PRICE("WTI_USD"), 81.78); - assert.equal( - harness.requests[0].options.headers.Authorization, - "Token legacy-key", + assert.match( + harness.context.OILPRICE_PRICE("WTI_USD"), + /^#AUTH_REQUIRED:/, ); + assert.equal(harness.requests.length, 0); harness.context.saveApiKey("spreadsheet-key"); assert.equal(harness.userPropertyValues.has("OILPRICEAPI_KEY"), false); @@ -498,7 +645,7 @@ test("quota failures are cached and repeated formulas do not refetch", () => { 402, JSON.stringify({ error_code: "PAYMENT_REQUIRED", - message: "You have used all requests for this month", + message: "You have used all requests for the current limit window", upgrade_url: "https://www.oilpriceapi.com/pricing", }), { "X-RateLimit-Reset": String(Math.floor(Date.now() / 1000) + 3600) }, @@ -528,6 +675,115 @@ test("a connection check bypasses a cached quota wall and clears it after upgrad assert.equal(harness.requests.length, 3); }); +test("a successful connection check invalidates every cached entitlement block", () => { + const harness = createHarness(); + configure(harness); + harness.queue(403, JSON.stringify({ error: "upgrade required" })); + assert.match( + harness.context.OILPRICE_HISTORY("WTI_USD", 30)[0][0], + /^#UPGRADE_REQUIRED$/, + ); + + harness.queue(200, latestBody()); + assert.equal(harness.context.testConnection().success, true); + harness.queue( + 200, + historyBody([ + { + code: "WTI_USD", + price: 82.45, + currency: "USD", + unit: "barrel", + source: "market_reporting", + created_at: "2026-08-11T12:05:00.000Z", + }, + ]), + ); + assert.deepEqual( + JSON.parse(JSON.stringify(harness.context.OILPRICE_HISTORY("WTI_USD", 30))), + [["2026-08-11T12:05:00.000Z", 82.45]], + ); + assert.equal(harness.requests.length, 3); +}); + +test("a fallback-user connection check invalidates that user's cached blocks", () => { + const harness = createHarness(); + harness.context.PropertiesService.getDocumentProperties = () => null; + harness.userPropertyValues.set("OILPRICEAPI_KEY:sheet-a", "fallback-user-key"); + harness.userPropertyValues.set("OILPRICEAPI_CACHE_GENERATION:sheet-a", "100"); + harness.queue(403, JSON.stringify({ error: "upgrade required" })); + assert.match( + harness.context.OILPRICE_HISTORY("WTI_USD", 30)[0][0], + /^#UPGRADE_REQUIRED$/, + ); + + harness.queue(200, latestBody()); + assert.equal(harness.context.testConnection().success, true); + assert.notEqual( + harness.userPropertyValues.get("OILPRICEAPI_CACHE_GENERATION:sheet-a"), + "100", + ); + harness.queue( + 200, + historyBody([ + { + code: "WTI_USD", + price: 83.1, + currency: "USD", + unit: "barrel", + source: "market_reporting", + created_at: "2026-08-11T12:10:00.000Z", + }, + ]), + ); + assert.deepEqual( + JSON.parse(JSON.stringify(harness.context.OILPRICE_HISTORY("WTI_USD", 30))), + [["2026-08-11T12:10:00.000Z", 83.1]], + ); + assert.equal(harness.requests.length, 3); +}); + +test("a fallback-user connection check reports cache invalidation failure", () => { + const harness = createHarness(); + harness.context.PropertiesService.getDocumentProperties = () => null; + harness.userPropertyValues.set("OILPRICEAPI_KEY:sheet-a", "fallback-user-key"); + harness.context.PropertiesService.getUserProperties = () => ({ + getProperty: (key) => harness.userPropertyValues.get(key) || null, + setProperty: () => { + throw new Error("Properties service unavailable"); + }, + deleteProperty: (key) => harness.userPropertyValues.delete(key), + }); + harness.queue(200, latestBody()); + + const result = harness.context.testConnection(); + assert.equal(result.success, false); + assert.match(result.message, /cached worksheet state.*test connection/i); +}); + +test("a document connection check fails closed when its generation write fails", () => { + const harness = createHarness(); + configure(harness); + harness.queue(403, JSON.stringify({ error: "upgrade required" })); + assert.match( + harness.context.OILPRICE_HISTORY("WTI_USD", 30)[0][0], + /^#UPGRADE_REQUIRED$/, + ); + + harness.context.PropertiesService.getDocumentProperties = () => ({ + getProperty: (key) => harness.documentPropertyValues.get(key) || null, + setProperty: () => { + throw new Error("Document properties unavailable"); + }, + deleteProperty: (key) => harness.documentPropertyValues.delete(key), + }); + harness.queue(200, latestBody()); + + const result = harness.context.testConnection(); + assert.equal(result.success, false); + assert.match(result.message, /cached worksheet state.*test connection/i); +}); + test("all worksheet formulas return readable errors instead of raw exceptions", () => { const harness = createHarness(); const scalarCalls = [ @@ -1464,3 +1720,24 @@ test("user info does not invent a tier or request limit", () => { assert.equal(info.limit, null); assert.equal(info.used, null); }); + +test("user info does not infer a quota window from legacy monthly fields", () => { + const harness = createHarness(); + configure(harness); + harness.queue( + 200, + JSON.stringify({ + data: { + tier: "free", + request_limit: 50, + requests_this_month: 5, + }, + }), + ); + + const info = harness.context.getUserInfo(); + assert.equal(info.tier, "free"); + assert.equal(info.limit, null); + assert.equal(info.used, null); + assert.equal(info.window, null); +}); diff --git a/test/secret-scan.test.js b/test/secret-scan.test.js new file mode 100644 index 0000000..010db5c --- /dev/null +++ b/test/secret-scan.test.js @@ -0,0 +1,43 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { + findPotentialSecretFiles, + formatFindings, +} = require("../scripts/scan-secrets.js"); + +test("secret scan detects credentials without printing their values", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "opa-secret-scan-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, "nested")); + fs.writeFileSync(path.join(root, "safe.txt"), "API key is configured in the sidebar.\n"); + const credential = `sk-${"x".repeat(24)}`; + fs.writeFileSync(path.join(root, "nested", "secret.txt"), `${credential}\n`); + + const findings = findPotentialSecretFiles(root); + assert.deepEqual(findings, ["nested/secret.txt"]); + const output = formatFindings(findings); + assert.match(output, /nested\/secret\.txt/); + assert.equal(output.includes(credential), false); +}); + +test("secret scan ignores dependency and scanner implementation paths", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "opa-secret-scan-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const credential = `AIza${"x".repeat(32)}`; + for (const relativePath of [ + "node_modules/package/token.txt", + "scripts/scan-secrets.js", + "test/secret-scan.test.js", + "package-lock.json", + ]) { + const target = path.join(root, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, credential); + } + + assert.deepEqual(findPotentialSecretFiles(root), []); +}); diff --git a/test/validate_code.js b/test/validate_code.js index d9c6847..44d0092 100644 --- a/test/validate_code.js +++ b/test/validate_code.js @@ -27,6 +27,7 @@ function validateFiles() { "appsscript.json", "docs/index.html", "package.json", + "scripts/scan-secrets.js", "scripts/scan-secrets.sh", "scripts/configure-clasp.js", "scripts/generate-marketplace-assets.js", @@ -34,6 +35,7 @@ function validateFiles() { "scripts/verify-marketplace-assets.js", "test/public-claims.test.js", "test/runtime.test.js", + "test/secret-scan.test.js", ]; for (const file of required) { assert.equal(fs.existsSync(path.join(ROOT, file)), true, `missing ${file}`); From cbf85a36f69dc026e05bbb8232e510d1850fdc3b Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Tue, 11 Aug 2026 08:49:53 -0400 Subject: [PATCH 2/2] fix: harden cache namespace and release audit --- .github/workflows/apps-script-release.yml | 1 + .github/workflows/validate.yml | 1 + Code.gs | 10 ++++-- TEST_RESULTS.md | 12 +++---- package-lock.json | 43 +++++++++++------------ package.json | 3 ++ scripts/scan-secrets.js | 2 ++ test/public-claims.test.js | 29 +++++++++++++-- test/runtime.test.js | 9 +++++ test/secret-scan.test.js | 10 ++++++ 10 files changed, 86 insertions(+), 34 deletions(-) diff --git a/.github/workflows/apps-script-release.yml b/.github/workflows/apps-script-release.yml index 58e3fe9..882d869 100644 --- a/.github/workflows/apps-script-release.yml +++ b/.github/workflows/apps-script-release.yml @@ -22,6 +22,7 @@ jobs: node-version: "24" cache: npm - run: npm ci + - run: npm audit --audit-level=moderate - run: npm run validate - name: Configure clasp credentials env: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a23bef1..033020e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,5 +19,6 @@ jobs: node-version: '24' cache: npm - run: npm ci + - run: npm audit --audit-level=moderate - run: npm run assets - run: npm run validate diff --git a/Code.gs b/Code.gs index 5c67ddd..78a23a8 100644 --- a/Code.gs +++ b/Code.gs @@ -415,6 +415,10 @@ function stableCacheDigest_(value) { return (hash >>> 0).toString(36); } +function combineCacheDigests_(primaryDigest, secondaryDigest) { + return `${primaryDigest}_${secondaryDigest}`; +} + function requestBlockKeys_(path) { const endpoint = requestEndpoint_(path); return { @@ -782,8 +786,10 @@ function namespacedCacheKey_(cacheKey, cacheScope) { if (cacheScope === 'user') { const spreadsheetId = getActiveSpreadsheetId_(); if (!spreadsheetId) return null; - const spreadsheetHash = - `${stableCacheDigest_(spreadsheetId)}${stableCacheDigest_(`sheet:${spreadsheetId}`)}`; + const spreadsheetHash = combineCacheDigests_( + stableCacheDigest_(spreadsheetId), + stableCacheDigest_(`sheet:${spreadsheetId}`) + ); return `opa_u_${spreadsheetHash}_${cacheGeneration_()}_${cacheKey}`; } return `opa_d_${cacheGeneration_()}_${cacheKey}`; diff --git a/TEST_RESULTS.md b/TEST_RESULTS.md index d6bbc31..66e3db3 100644 --- a/TEST_RESULTS.md +++ b/TEST_RESULTS.md @@ -66,15 +66,13 @@ Covered behavior includes: Command: ```bash -npm audit --omit=dev +npm audit --audit-level=moderate ``` -Result: 0 runtime vulnerabilities. - -The current official clasp development dependency reports moderate transitive -development-tool advisories. It is not shipped to Apps Script. Do not use clasp -to serve untrusted local files; update it when Google publishes a dependency -refresh. +Result on 2026-08-11: 0 vulnerabilities. The audit includes the development +toolchain used to validate and publish the Apps Script package. `@google/clasp` +remains pinned to `3.3.0`; the lockfile override resolves its transitive `uuid` +dependency to `11.1.1` without downgrading the release CLI. ## Production API smoke diff --git a/package-lock.json b/package-lock.json index c6ae69b..736c64e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -175,13 +175,13 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.15", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.15.tgz", - "integrity": "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -1113,13 +1113,13 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "dev": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -1972,9 +1972,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -2336,9 +2336,9 @@ } }, "node_modules/hono": { - "version": "4.12.32", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", - "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "dev": true, "license": "MIT", "engines": { @@ -2637,9 +2637,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "dev": true, "license": "MIT", "engines": { @@ -4074,10 +4074,9 @@ "license": "BSD" }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -4085,7 +4084,7 @@ ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/validate-npm-package-license": { diff --git a/package.json b/package.json index 720fb5c..1238ef3 100644 --- a/package.json +++ b/package.json @@ -27,5 +27,8 @@ "devDependencies": { "@google/clasp": "3.3.0", "sharp": "0.35.3" + }, + "overrides": { + "uuid": "11.1.1" } } diff --git a/scripts/scan-secrets.js b/scripts/scan-secrets.js index 4d754e9..32f26e7 100644 --- a/scripts/scan-secrets.js +++ b/scripts/scan-secrets.js @@ -4,6 +4,8 @@ const fs = require("node:fs"); const path = require("node:path"); const PATTERNS = [ + // Production API keys are bare 64-character hex values. Keep this exact + // detector; known checksum-bearing files are allowlisted by path below. /[0-9a-fA-F]{64}/, /sk-[A-Za-z0-9_-]{20,}/, /AIza[A-Za-z0-9_-]{30,}/, diff --git a/test/public-claims.test.js b/test/public-claims.test.js index 1988f31..826d617 100644 --- a/test/public-claims.test.js +++ b/test/public-claims.test.js @@ -70,9 +70,17 @@ test("operator records preserve the exact release and Google submission state", assert.match(runtime, /ADDON_VERSION = '1\.3\.1'/); assert.match(deployment, /Public runtime version: `1\.2\.2`/); assert.match(deployment, /Repository release candidate: `1\.3\.1`/); - assert.match(deployment, /Latest immutable Apps Script version: `12`/); - assert.match(listing, /Public Marketplace Apps Script version: `11`/); - assert.match(records, /version 12[\s\S]{0,240}never published/i); + assert.match( + deployment, + /Latest immutable Apps Script version: `12` \(runtime `1\.3\.0`/, + ); + assert.match( + listing, + /Public Marketplace Apps Script version: `11` \(runtime `1\.2\.2`\)/, + ); + for (const record of [deployment, listing, oauth]) { + assert.match(record, /version 12[\s\S]{0,240}never published/i); + } assert.match(records, /publicly available/i); assert.match( records, @@ -121,6 +129,21 @@ test("legacy credential migration requires an explicit spreadsheet reconfigure", assert.match(readme, /unscoped keys[\s\S]{0,260}save the key again/i); }); +test("validation and production release workflows block on dependency audit", () => { + for (const workflow of ["validate.yml", "apps-script-release.yml"]) { + const contents = fs.readFileSync( + path.join(ROOT, ".github", "workflows", workflow), + "utf8", + ); + assert.match(contents, /npm audit --audit-level=moderate/); + } + + const results = fs.readFileSync(path.join(ROOT, "TEST_RESULTS.md"), "utf8"); + assert.match(results, /npm audit --audit-level=moderate/); + assert.match(results, /0 vulnerabilities/); + assert.doesNotMatch(results, /reports moderate transitive[\s\S]{0,120}advisories/i); +}); + test("public surfaces contain no unsupported mutable claims", () => { const text = PUBLIC_FILES.map((file) => fs.readFileSync(path.join(ROOT, file), "utf8"), diff --git a/test/runtime.test.js b/test/runtime.test.js index 87eae20..fff9b05 100644 --- a/test/runtime.test.js +++ b/test/runtime.test.js @@ -940,6 +940,15 @@ test("request block keys include a bounded path prefix as well as a hash", () => assert.ok(keys.request.length < 180); }); +test("cache digest pairs preserve their namespace boundary", () => { + const harness = createHarness(); + assert.equal(harness.context.combineCacheDigests_("ab", "cde"), "ab_cde"); + assert.notEqual( + harness.context.combineCacheDigests_("ab", "cde"), + harness.context.combineCacheDigests_("abc", "de"), + ); +}); + test("latest values use the document cache across spreadsheet viewers", () => { const harness = createHarness(); configure(harness); diff --git a/test/secret-scan.test.js b/test/secret-scan.test.js index 010db5c..cf74048 100644 --- a/test/secret-scan.test.js +++ b/test/secret-scan.test.js @@ -24,6 +24,16 @@ test("secret scan detects credentials without printing their values", (t) => { assert.equal(output.includes(credential), false); }); +test("secret scan detects bare 64-hex production API keys", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "opa-secret-scan-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const credential = "a".repeat(64); + fs.writeFileSync(path.join(root, "credential.txt"), credential); + + assert.deepEqual(findPotentialSecretFiles(root), ["credential.txt"]); + assert.equal(formatFindings(["credential.txt"]).includes(credential), false); +}); + test("secret scan ignores dependency and scanner implementation paths", (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "opa-secret-scan-")); t.after(() => fs.rmSync(root, { recursive: true, force: true }));