Back to skill

Security audit

Tinker LinkedIn

Security checks for vulnerabilities and agentic risk

Overview

The skill is mostly transparent about automating a shared LinkedIn tab, but it deserves review because it can access sensitive LinkedIn data and one legacy-session check still loads an old session secret despite documentation saying no secret is read.

Install only if you are comfortable giving an agent access to a signed-in LinkedIn tab, including inbox and connection data. Use a dedicated browser profile if possible, keep the tab shared only while actively using the skill, avoid large --top values, run legacy cleanup deliberately, and review the keychain-status behavior before trusting the claim that no old session secret is ever read.

Vulnerability Patterns
  • Insecure Skill Coding PracticesFinds exploitable flaws such as hardcoded secrets or command injection
  • 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 (3)

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/linkedin.mjs:101
Finding
Legacy keychain credential is unnecessarily loaded into process memory<![CDATA[ ## Vulnerability Details **File Location**: `scripts/linkedin.mjs:101-115`, invoked by `scripts/linkedin.mjs:188-191` and `scripts/linkedin.mjs:704-723` **Vulnerability Type**: Credential overexposure during a presence check **Risk Level**: Medium ### Vulnerable Code ```js function keychainGet() { try { const argv = KEYCHAIN_BIN === 'security' ? ['find-generic-password', '-s', KEYRING_SERVICE, '-a', KEYRING_ACCOUNT, '-w'] : ['lookup', 'service', KEYRING_SERVICE, 'account', KEYRING_ACCOUNT]; const v = execFileSync(KEYCHAIN_BIN, argv, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }); const trimmed = String(v || '').replace(/\n$/, ''); return trimmed || null; } catch { return null; } } ``` The full value is retrieved merely to determine whether an entry exists: ```js function legacyCredsPresent() { return Boolean(keychainGet()) || existsSync(TOKEN_FILE); } ``` ### Technical Analysis The `session status` operation only needs a Boolean indication of whether a legacy keychain entry exists. Instead, `keychainGet()` invokes a platform credential command that returns the complete credential and captures that value in Node.js memory. The legacy entry is documented as potentially containing a password-equivalent LinkedIn session. Although the value is not printed or transmitted, retrieving it expands its exposure from the operating-system keychain into the Node.js heap and child-process output pipe. This is unnecessary for the declared presence-check functionality and contradicts the documentation's assertion that no cookie value is read into the process. The implementation therefore exceeds least-privilege requirements for `session status`. ### Attack Path 1. A LinkedIn session credential left by version 1.2.0 or earlier remains in the operating-system keychain. 2. The user or Agent runs `linkedin session status`. 3. `legacyCredsPresent()` calls `keychainGet()`. 4. `security . ...[truncated 841 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Replace value retrieval with a metadata-only existence query where the platform supports one. 2. On macOS, use a keychain query that checks the command's exit status without requesting `-w`, so the password is never returned. 3. If `secret-tool` cannot perform a metadata-only presence query, do not probe the legacy entry during `session status`. Report the state as unknown or instruct the user to run the explicit cleanup operation. 4. Keep secret retrieval entirely out of Node.js. For logout, invoke only the fixed deletion command and use its exit status to report whether deletion succeeded. 5. Update tests to install a mock keychain executable that fails if the status operation requests or captures the secret value. 6. Correct the documentation so that its credential-handling claims precisely match platform behavior. ]]>

T09 · Insecure Skill Coding Practices

Note
Location
scripts/linkedin.mjs:142
Finding
Markerless JSON files at fixed paths are treated as Skill-owned and deleted<![CDATA[ ## Vulnerability Details **File Location**: `scripts/linkedin.mjs:142-153`, used by `scripts/linkedin.mjs:156-171` and `scripts/linkedin.mjs:681-697` **Vulnerability Type**: Insufficient ownership validation before file deletion **Risk Level**: Low ### Vulnerable Code ```js function isOwnedFile(file) { let parsed; try { parsed = JSON.parse(readFileSync(file, 'utf8')); } catch { return false; } if (parsed && parsed._openclaw_skill === MARKER) return true; // Files written by <=1.1.0 predate the marker. Accept them only at this skill's own // three fixed paths, so the off switch keeps working for existing installs. return OWNED_FILES.has(file) && parsed !== null && typeof parsed === 'object'; } ``` The ownership result controls deletion: ```js function safeUnlink(file) { const home = homedir(); if (!isAbsolute(file)) throw new Error(`refusing to delete a relative path: ${file}`); if (!file.startsWith(home + sep)) throw new Error(`refusing to delete outside $HOME: ${file}`); let st; try { st = lstatSync(file); } catch { return false; } if (st.isSymbolicLink()) throw new Error(`refusing to delete a symlink: ${file}`); if (!st.isFile()) throw new Error(`refusing to delete a non-regular file: ${file}`); if (!isOwnedFile(file)) { throw new Error(`refusing to delete ${file}: it carries no ${MARKER} marker, so this skill did not create it`); } unlinkSync(file); return true; } ``` ### Technical Analysis The deletion guard claims that a file must carry the Skill's ownership marker. However, `isOwnedFile()` accepts any JSON object or array at a path contained in `OWNED_FILES`, even when the marker is absent. The fixed set includes the legacy credential file, activity file, and cache file. Consequently, path membership and syntactically valid JSON are treated as sufficient proof of ownership. This is broader than necessary for backward compatibility and conflicts with the documented marker require ...[truncated 1331 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Require `_openclaw_skill === MARKER` for current activity and cache files without exception. 2. Restrict markerless backward compatibility to the specific legacy credential path and versions that actually created markerless files. 3. Validate a strict expected legacy schema rather than accepting every JSON object or array. 4. Reject arrays and objects that do not contain required legacy fields and expected value types. 5. Before deleting a markerless legacy file, optionally rename it to a quarantine filename or require explicit confirmation. 6. Add tests proving that markerless arbitrary JSON at the activity and cache paths is refused. 7. Update documentation to disclose any intentionally retained markerless legacy exception. ]]>

T09 · Insecure Skill Coding Practices

Note
Location
scripts/linkedin.mjs:1082
Finding
Unbounded result-count arguments bypass published daily read ceilings<![CDATA[ ## Vulnerability Details **File Location**: `scripts/linkedin.mjs:1082-1120` **Vulnerability Type**: Insufficient input bounds and inaccurate rate accounting **Risk Level**: Low ### Vulnerable Code ```js async function cmdMessages(args) { const conv = args._[0]; if (!conv) { console.error('Usage: linkedin messages <conversationUrn> [--top 30]'); process.exit(1); } const top = parseInt(args.flags.top, 10) || 30; assertUnderLimit('messages_read', 1); const urn = encodeURIComponent(conv); const data = await voyager(`/messaging/conversations/${urn}/events?count=${top}&q=syncToken`).catch( async () => voyager(`/messaging/conversations/${urn}/events?count=${top}`), ); bumpActivity('messages_read', Math.min(top, 10)); const events = (data.included || data.elements || []) .filter((x) => (x.$type || '').includes('Event') || x.eventContent || x.body) .map((e) => ({ urn: e.entityUrn, at: e.createdAt ? new Date(e.createdAt).toISOString() : null, from: e.from || e['*from'], text: e.eventContent?.attributedBody?.text || e.eventContent?.body?.text || e.body?.text || e.commentary?.text || null, type: e.subtype || e.$type, })) .filter((e) => e.text) .slice(0, top); out({ conversation: conv, count: events.length, events: events.length ? events : data }); } ``` Similar fixed-cost or capped accounting occurs in commands such as `connections` and `conversations`, while their `--top` inputs are not bounded. ### Technical Analysis The CLI parses `--top` as an integer but does not enforce a conservative maximum. For message reads, the pre-request guard checks only one unit: ```js assertUnderLimit('messages_read', 1); ``` After the request, it records at most ten units regardless of the requested result count: ```js bumpActivity('messages_read', Math.min(top, 10)); ``` An Agent can therefore request a result count substantially larger than th ...[truncated 1487 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Define explicit per-command maxima for `--top`, `--start`, and page values. 2. Reject non-integer, non-positive, or out-of-range inputs rather than silently accepting them. 3. Charge the pre-request rate guard according to the requested result count or a documented conservative request cost. 4. After receiving a response, reconcile counters against the number of records actually returned. 5. Ensure fallback requests cannot cause double reads without corresponding accounting. 6. Apply consistent validation to `messages`, `conversations`, `connections`, search, feed, notifications, and posts. 7. Add tests using very large, negative, fractional, and malformed values to confirm they are refused. 8. Document whether ceilings count API calls, records requested, or records returned, and implement that definition consistently. ]]>
Vulnerability Patterns
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • Trigger AbuseOverly Broad Trigger, Shadow Command Trigger, Keyword Baiting Trigger
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
Findings (4)

Intent-Code Divergence

Medium
Confidence
97% confidence
Finding
The documentation is internally inconsistent: it first claims cookie extraction/storage was deleted, then later says the skill will 'extract the session once.' For a skill operating on password-equivalent LinkedIn sessions, contradictory setup instructions are dangerous because users or downstream agents may attempt a weaker legacy flow or misunderstand whether credentials ever leave the browser.

Vague Triggers

Medium
Confidence
72% confidence
Finding
The skill description is broad and highly capable: it enables crawling profiles, search, connections, inbox, and feed from a live authenticated LinkedIn session without tightly scoped trigger boundaries. In an agent ecosystem, vague activation language increases the risk of overbroad invocation and unintended access to sensitive third-party data or user communications.

Autonomous Decision Making

Medium
Category
Excessive Agency
Content
Commands that would exceed a counter throw instead of calling Voyager.

`message-send` additionally requires **per-action** consent: `--i-mean-it` must repeat the exact conversation URN you are writing to, so an approval for one recipient cannot be reused on another. No consent, or consent naming a different conversation → the draft is printed, exit 2, no request.

Usage guidance for the agent, under the ceilings — this is about not wasting requests and keeping
a human in the loop, not about staying invisible:
Confidence
84% confidence
Finding
The skill permits broad authenticated reading actions against LinkedIn data while only the message-send path is explicitly gated by per-action consent. That means an agent could still autonomously search people, enumerate connections, read conversations, messages, or feed data from a signed-in session without an equivalent confirmation step.

Credential Access

High
Category
Privilege Escalation
Content
- **Your LinkedIn session cookies — indirectly, and it never sees them.** Treat `li_at` as a password: it is a full sign-in as you, with no second factor. This version has no code that reads it. The browser attaches it to a same-origin request; the skill neither receives nor stores the value.
- **It navigates and scrapes that tab.** Naming this plainly, because it is more than passive API use: people-search works by sending the shared tab to a LinkedIn search URL and reading the rendered DOM (LinkedIn's SDUI broke the classic Voyager search endpoint). It is browser automation on a live, signed-in session, and you should treat it as such.
- **Two files, both mode 0600, both under `~/.openclaw/workspace/memory`** — the rate-guard counters (`linkedin-activity.json`) and the opt-in crawl cache (`linkedin-cache.json`). No credential file is written under any flag. It reads nothing else on your disk.
- **One binary per platform, run with a fixed argument list and no shell** — `secret-tool` (Linux) or `security` (macOS), used **only** to detect and erase a keychain entry an older version could have created.
- **The local browser relay, and only on loopback.** `LINKEDIN_CDP_URL` / `LINKEDIN_RELAY_HTTP` are overridable, so both are parsed and their hostnames **resolved** before use, and every answer must be loopback. Anything else is refused outright — the `LINKEDIN_ALLOW_REMOTE_RELAY` escape hatch has been removed, because that channel is full control of a tab that is signed in as you.

### Where the secret lives
Confidence
78% confidence
Finding
Even if the skill never directly reads cookies, it explicitly automates a live signed-in LinkedIn tab, performs same-origin fetches, navigates the page, and scrapes authenticated data. That gives the skill effective access to password-equivalent session authority over sensitive account data, so compromise or misuse of the skill would expose inbox, profile, and connection data at high impact.

Static analysis

Detected: suspicious.dangerous_exec

Shell command execution detected (child_process).

Critical
Code
suspicious.dangerous_exec
Location
scripts/linkedin.mjs:108

Shell command execution detected (child_process).

Critical
Code
suspicious.dangerous_exec
Location
tests/cli.test.mjs:26