Back to skill

Security audit

Tinker LinkedIn

Security checks for vulnerabilities and agentic risk

Overview

The skill largely matches its LinkedIn automation purpose, but it needs review because one status path reads an old LinkedIn session secret despite documentation saying credential values are never read.

Review before installing if you ever used an older version that stored a LinkedIn session. The skill does not show exfiltration or destructive behavior, but running session status may read a password-equivalent legacy session into memory; prefer a version that checks or deletes legacy credentials without reading their value.

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/linkedin.mjs:102
Finding
Legacy LinkedIn Session Secret Is Unnecessarily Read into Process Memory<![CDATA[ ## Vulnerability Details **File Location**: `scripts/linkedin.mjs:102-114` and `scripts/linkedin.mjs:189-190` **Vulnerability Type**: Excessive credential access and sensitive data exposure **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; } } ``` ```js function legacyCredsPresent() { return Boolean(keychainGet()) || existsSync(TOKEN_FILE); } ``` ### Technical Analysis The `session status` operation only needs to determine whether a legacy keychain entry exists. However, `legacyCredsPresent()` calls `keychainGet()`, which retrieves the complete stored credential. On macOS, the `security find-generic-password` command uses the `-w` option, which prints the password. On Linux, `secret-tool lookup` similarly writes the matching secret to standard output. The process captures that output through `execFileSync`, creating JavaScript strings containing the password-equivalent LinkedIn session credential. This behavior exceeds the minimum privileges necessary for a presence check. It also contradicts the Skill documentation stating that no credential value is read into the process and that the keychain functionality is limited to detecting and deleting legacy entries. The value is not directly printed or transmitted by the reviewed code. Nevertheless, unnecessary retrieval expands the credential's exposure surface to Node.js process memory, runtime instrumentation, debuggers, crash collection, malicious preload hooks, and future accidental logging. ### Attack Path 1. ...[truncated 1789 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Replace secret retrieval with a metadata-only keychain existence check. - On macOS, invoke `security find-generic-password` without `-w` and use only the process exit status. - Do not pipe or capture password output. 2. On Linux, use a keyring interface that can query item metadata or existence without returning the secret. 3. If the available Linux command cannot check existence without disclosing the value, remove the keychain-presence indicator from `session status`. Preserve fixed-argument deletion only during the explicit `session logout` operation. 4. Separate the APIs by intent: - `keychainExists()` must never return or capture credential contents. - `keychainDelete()` should remain deletion-only. - No general-purpose credential getter should exist in this version. 5. Add platform-specific tests with mocked `security` and `secret-tool` binaries. The tests should verify that: - macOS arguments never include `-w`. - Status checks do not emit or capture a sentinel secret. - Logout still deletes the fixed legacy keychain entry. 6. Update documentation only after implementation and tests confirm that no legacy credential value enters process memory. ]]>
Vulnerability Patterns
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
  • 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 (10)

Intent-Code Divergence

Medium
Confidence
96% confidence
Finding
The top-level prose says the skill will 'extract the session once,' which directly contradicts the repeated claim elsewhere that session extraction was removed in 1.2.1. For a skill operating against a password-equivalent browser session, inaccurate security documentation is dangerous because users and downstream agents may believe credential extraction still occurs, or may build workflows and trust assumptions around the wrong data flow.

Credential Access

High
Category
Privilege Escalation
Content
}

// Each helper runs one fixed binary with a fixed argument list and no shell, and
// returns false/null rather than throwing when the keychain is simply absent.
function keychainGet() {
  try {
    const argv =
Confidence
75% confidence
Finding
`keychainGet()` reads a legacy LinkedIn session value from the system keychain using `find-generic-password ... -w` or `secret-tool lookup`, which retrieves a password-equivalent secret into this process. Even though the stated purpose is migration/status cleanup, any code path that reads a live session secret increases exposure if the process is compromised, logs are added later, or downstream code is modified to reuse the value.

Credential Access

High
Category
Privilege Escalation
Content
// Each helper runs one fixed binary with a fixed argument list and no shell, and
// returns false/null rather than throwing when the keychain is simply absent.
function keychainGet() {
  try {
    const argv =
      KEYCHAIN_BIN === 'security'
Confidence
75% confidence
Finding
This line is part of the `keychainGet()` implementation that conditionally builds arguments to read a secret from the keychain. Reading a password-equivalent credential into process memory is sensitive behavior and can be abused if the process or future code paths are compromised.

Credential Access

High
Category
Privilege Escalation
Content
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, {
Confidence
80% confidence
Finding
Using `find-generic-password ... -w` explicitly requests the stored secret value, not just metadata. That makes the process handle a credential that could authenticate to LinkedIn outside the browser in older versions, which is unnecessary for simple cleanup/status and increases risk surface.

Credential Access

High
Category
Privilege Escalation
Content
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',
Confidence
80% confidence
Finding
The line contributes to building the secret-retrieval arguments for the keychain lookup. In context, it is not malicious, but it still performs credential access beyond what is strictly needed for cleanup visibility.

Credential Access

High
Category
Privilege Escalation
Content
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',
Confidence
80% confidence
Finding
The line contributes to building the secret-retrieval arguments for the keychain lookup. In context, it is not malicious, but it still performs credential access beyond what is strictly needed for cleanup visibility.

Credential Access

High
Category
Privilege Escalation
Content
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'],
Confidence
72% confidence
Finding
Although this line itself is part of setup, it belongs to a code path that reads a legacy session secret into memory. The overall behavior is sensitive credential access, even if the author's stated intent is cleanup rather than theft.

Credential Access

High
Category
Privilege Escalation
Content
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'],
Confidence
72% confidence
Finding
Although this line itself is part of setup, it belongs to a code path that reads a legacy session secret into memory. The overall behavior is sensitive credential access, even if the author's stated intent is cleanup rather than theft.

Credential Access

High
Category
Privilege Escalation
Content
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'],
    });
Confidence
77% confidence
Finding
`execFileSync(KEYCHAIN_BIN, argv, ...)` executes the keychain utility and captures its output, which in this path is the secret itself. That is a genuine credential-access operation and creates unnecessary exposure to a legacy LinkedIn session token.

Credential Access

High
Category
Privilege Escalation
Content
// True when a session stored by version <= 1.2.0 is still lying around. Surfaced by
// `session status` so it is visible, and removed by `session logout`.
function legacyCredsPresent() {
  return Boolean(keychainGet()) || existsSync(TOKEN_FILE);
}

// ─── Activity / rate guard ───
Confidence
76% confidence
Finding
`legacyCredsPresent()` calls `keychainGet()`, which reads the actual legacy secret to determine presence. That means even a status command materializes a password-equivalent token in process memory unnecessarily, increasing exposure despite the benign stated purpose.

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