Back to skill

Security audit

Google Sheets Agent

Security checks for vulnerabilities and agentic risk

Overview

This Google Sheets skill is mostly coherent, but commands described as read-only actually request write-capable Google Sheets access.

Install only if you are comfortable giving the service account write-capable authorization to spreadsheets it can access. Prefer sharing sheets with Viewer access unless writes are needed, use a dedicated least-privilege service account, and consider fixing the script so read/meta use spreadsheets.readonly before relying on it for sensitive sheets.

Vulnerability Patterns
  • Unauthorized Access and Privilege EscalationObtains permissions beyond the task's legitimate needs
  • Skill Instruction HijackingAlters the agent's session goals or safety constraints when the skill loads
  • Agent Memory PoisoningWrites attacker-controlled rules into memory that affect later sessions
  • Remote Payload Retrieval and ExecutionFetches external code whose behavior can change after review
  • Embedded Malicious CodeShips malicious scripts inside the skill and executes them locally
Findings (1)

T05 · Unauthorized Access and Privilege Escalation

Warning
Location
scripts/sheets.mjs:47
Finding
Read-Only Commands Request Full Google Sheets Read/Write Access<![CDATA[ ## Vulnerability Details **File Location**: `scripts/sheets.mjs`, lines 47–113 **Vulnerability Type**: Excessive OAuth permissions and violation of least privilege **Risk Level**: Medium The `read` and `meta` commands request the full `https://www.googleapis.com/auth/spreadsheets` OAuth scope rather than the read-only scope documented in `SKILL.md`. ### Vulnerable Code ```js async function getAccessToken(scope = 'https://www.googleapis.com/auth/spreadsheets') { if (_token && Date.now() < _tokenExp - 60000) return _token; const key = await loadSAKey(); const now = Math.floor(Date.now() / 1000); const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); const payload = Buffer.from(JSON.stringify({ iss: key.client_email, scope, aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600, })).toString('base64url'); const sig = crypto.sign('RSA-SHA256', Buffer.from(`${header}.${payload}`), key.private_key).toString('base64url'); const jwt = `${header}.${payload}.${sig}`; const body = `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`; const res = await httpReq('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }, body); const tok = JSON.parse(res); if (!tok.access_token) throw new Error(`Token exchange failed: ${res}`); _token = tok.access_token; _tokenExp = Date.now() + tok.expires_in * 1000; return _token; } ``` ```js async function sheetsApi(path, method = 'GET', body = null) { const token = await getAccessToken(); const url = `https://sheets.googleapis.com/v4/spreadsheets/${path}`; const opts = { method, headers: { Authorization: `Bearer ${token}` } }; if (body) opts.headers['Content-Type'] = 'application/json'; return JSON.parse(await httpReq(url, opts, body ? JSON.stringify(body) : null)); } ``` ```js async function readSheet(sheetId, range = ...[truncated 3229 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Require callers to provide an explicit scope rather than defaulting to the full Sheets scope. ```js const SCOPES = { SHEETS_READONLY: 'https://www.googleapis.com/auth/spreadsheets.readonly', SHEETS_READWRITE: 'https://www.googleapis.com/auth/spreadsheets', DRIVE_READONLY: 'https://www.googleapis.com/auth/drive.readonly', }; async function getAccessToken(scope) { if (!scope) throw new Error('An explicit OAuth scope is required'); // Token creation logic... } ``` 2. Pass the read-only scope for metadata and cell-reading operations. ```js async function sheetsApi(path, method = 'GET', body = null, scope) { const token = await getAccessToken(scope); const url = `https://sheets.googleapis.com/v4/spreadsheets/${path}`; const opts = { method, headers: { Authorization: `Bearer ${token}` } }; if (body) opts.headers['Content-Type'] = 'application/json'; return JSON.parse(await httpReq(url, opts, body ? JSON.stringify(body) : null)); } async function readSheet(sheetId, range = 'Sheet1!A:ZZ') { return sheetsApi( `${sheetId}/values/${encodeURIComponent(range)}`, 'GET', null, SCOPES.SHEETS_READONLY ); } async function getMeta(sheetId) { const res = await sheetsApi( sheetId, 'GET', null, SCOPES.SHEETS_READONLY ); return { title: res.properties?.title, sheets: (res.sheets || []).map(s => ({ title: s.properties.title, sheetId: s.properties.sheetId, rowCount: s.properties.gridProperties?.rowCount, colCount: s.properties.gridProperties?.columnCount, })), }; } ``` 3. Use the full Sheets scope only for `appendRows()` and `writeRange()`. 4. Cache tokens by scope instead of using one global token slot. ```js const tokenCache = new Map(); async function getAccessToken(scope) { const cached = tokenCache.get(scope); if (cached && Date.now() < cached.expiresAt - 60000) { return cached.token; } // Create a token for the requested s ...[truncated 456 chars]
Vulnerability Patterns
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Supply ChainUnpinned Dependencies, External Script Fetching, Obfuscated Code
Findings (3)

Ae1

High
Category
analysis-evasion
Content
SHEETS=scripts/sheets.mjs
Confidence
100% confidence
Finding
Referenced artifact was not completely inspected

Credential Access

High
Category
Privilege Escalation
Content
Node.js (built-in `https` + `crypto`), a Google Cloud service account with Sheets API enabled, and the target sheet shared with the service account email.

**How does authentication work?**
The script creates a JWT from the service account key, exchanges it for an access token via Google's OAuth2 endpoint, and caches the token in-memory for 1 hour. Supports 1Password, environment variable, or file-based key loading.

**How much does it cost?**
Google Sheets API is free for standard usage. The service account is free. No paid dependencies.
Confidence
70% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Lp3

Medium
Category
MCP Least Privilege
Confidence
91% confidence
Finding
The skill explicitly relies on sensitive environment/file/1Password-based credential access but does not declare any tool scope or permissions boundary in the skill metadata. In an agent ecosystem, this creates an authorization gap: a user or orchestrator cannot easily tell that the skill expects secret material and may permit broader-than-intended access to local secrets.

Static analysis

No suspicious patterns detected.