Back to Activepieces

Triage Security Advisories (reported vulnerabilities)

.agents/skills/triage-security-advisories/SKILL.md

0.86.314.7 KB
Original Source

Triage Security Advisories (reported vulnerabilities)

On-demand triage of privately-reported vulnerabilities in the Activepieces GitHub repo (activepieces/activepieces) — the repository security advisories from the Security tab. Produces a review-ready report per advisory + an SLA dashboard, and proposes fix plans. The user reviews and decides per advisory: approve the fix / dismiss as out-of-scope / escalate.

For Dependabot dependency alerts, use the triage-dependabot-alerts skill. The two share the .security-triage/ workspace, the privacy rule, and the SLA script.

Follow the manual process this automates: docs/handbook/engineering/playbooks/security-advisory-response.mdx.

HARD PRIVACY RULE — read first

This is a public repo. Private/embargoed advisory content MUST NOT be committed.

  • Write all artifacts (fetched JSON, per-advisory reports, dashboard) to .security-triage/, which is gitignored. Never write advisory content into any tracked file.
  • After a run, confirm git status shows nothing new under tracked paths.
  • Fixes go to a private-fork security/<ghsa-id> branch per the playbook — never a public pre-embargo PR.

One-time setup

The gh token needs security scopes or the advisory API returns 403. If step 1 returns 403, tell the user to run:

gh auth refresh -s security_events,repo

Step 1 — Fetch (deterministic)

mkdir -p .security-triage
gh api /repos/activepieces/activepieces/security-advisories --paginate > .security-triage/advisories.json

gh api --paginate may emit concatenated arrays (you'll see jq parse multiple top-level values, e.g. lengths 100 then 5); if so merge into one array (jq -s 'add'). On 403, surface the setup command above and stop.

zsh JSON footgun (hit this every run): never pipe advisory rows through echo/printf into jqecho "$row" | jq … corrupts embedded control chars/backslashes and fails with Invalid string: control characters … must be escaped. To split the merged array into one input file per advisory, loop the ids and let jq read the source file directly each time (no shell string round-trip):

mkdir -p .security-triage/input .security-triage/reports
for id in $(jq -r '.[] | select(.state=="triage" or .state=="draft") | .ghsa_id' .security-triage/advisories.json); do
  jq --arg id "$id" '.[] | select(.ghsa_id==$id) | {ghsa_id, cve_id, summary, severity, state, created_at, cvss: .cvss_severities, cwes: .cwe_ids, description}' \
    .security-triage/advisories.json > ".security-triage/input/${id}.json"
done

Then hand each subagent its input/<ghsa>.json path to read — keeps the (private, embargoed) advisory body out of the orchestrator's context and out of subagent prompts.

No manual state filtering needed — the SLA script (step 4) excludes resolved advisories (closed/published/withdrawn) by default. The actionable backlog is state triage (newly reported, un-assessed → full pipeline) and draft (accepted, fix in flight → SLA-track only). Use --state triage to focus on un-assessed only, or --state all to include resolved. When the user asks for "triage state only", pass --state triage AND filter the input-file loop above to select(.state=="triage").

Step 2 — Filter + scope-check

For each advisory, check it against the out-of-scope list in SECURITY.md: clickjacking on non-sensitive pages, unauthenticated/logout/login CSRF, MITM/physical-access attacks, DoS, content/text injection without an attack vector, email spoofing, missing DNSSEC/CAA/CSP, non-sensitive cookie flags, deadlinks, UNSANDBOXED execution mode, special-char inputs with no exploitable sink, capability-token guessing without a disclosure path, and pure high-entropy-identifier (e.g. nanoid) guessing.

If a report matches an out-of-scope item, mark it OUT_OF_SCOPE with the exact SECURITY.md clause and reasoning.

Step 3 — Deep validate (one focused subagent per advisory)

Do NOT stop at "the report mentions X." For each in-scope advisory, trace the full path from entry point to sink and gather evidence the user can agree with or overrule:

  • Treat the reporter's file:line AND stated mechanism as a hint, not ground truth. Nearly every advisory this codebase produces has drifted citations — refactors move code (packages/sharedpackages/core/shared), rewrites swap the sink (axios→native fetch, worker→sandbox), line numbers shift. Re-locate the sink on current main by symbol/string search. The vuln can be real while the cited location is stale, and the reported root cause can be wrong even when a bug exists nearby (e.g. a reported bypass mechanism turns out to be irrelevant and the real flaw is elsewhere on the path). Verify the mechanism, not just that "something looks off near there."
  • Locate the actual sink (file:line) and every reachable route/caller that reaches it.
  • Inspect the guards in between and decide if the vuln is truly reachable for an attacker, not merely present in source:
    • Auth: securityAccess config on the endpoint (every endpoint must have one).
    • Tenant isolation: queries must filter by projectId / platformId (.claude/rules/data-isolation.md); connections use ArrayContains([projectId]).
    • Input validation (zod schemas), and edition gating (platformMustHaveFeatureEnabled, EE-only paths) — .claude/rules/edition-safety.md.
    • SSRF: outbound HTTP should use safeHttp (.claude/rules/safe-http.md); a raw fetch/axios.create on user input is a real reachable sink.
  • Default-on vs opt-in, and which edition — this flips real severity. Check: (a) which edition registers the module (the app.ts edition switch — many sinks are Cloud-only or EE-only); (b) whether the vulnerable path is default-enabled or behind an env flag (admin/debug UIs often are); (c) whether the protective mode is opt-in (e.g. an SSRF guard that only runs in a non-default network mode). A real bug reachable only in a non-default/opt-in or single-edition config is materially lower severity — say so.
  • Confirm the affected version range against current main — already patched? did a refactor remove the sink? Use git log -S'<sink string>' / git blame to find the fix commit. If ALREADY_MITIGATED, record the fix commit SHA + date and compare it to the advisory's created_at: reports filed after the fix landed are common here (one critical was fixed ~5 weeks before it was reported). The fix date drives the close-out message and proves "BREACHED" on the dashboard is stale.
  • Cross-check CVSS 4.0 scoring inputs from the playbook (attack vector, privileges, user interaction, scope, CIA impact). Buckets: 0.1–3.9 low, 4.0–6.9 medium, 7.0–8.9 high, 9.0–10 critical.
  • Produce a concrete PoC sketch / failing-test outline, or state precisely why it cannot be triggered.

Emit exactly one verdict per advisory, each backed by file:line evidence:

VerdictMeaning
CONFIRMED_EXPLOITABLEReachable and exploitable as described.
THEORETICALSink present but not reachable (guarded / not wired up).
ALREADY_MITIGATEDA guard or prior fix already blocks it.
FALSE_POSITIVENot a real vulnerability.
OUT_OF_SCOPEExcluded by SECURITY.md.

Also flag duplicates (same sink/root cause) and systemic patterns (one fix covers several). For a large backlog, fan out one subagent per advisory in parallel, then synthesize. Subagents stay read-only; they write a report file (reports/<ghsa>.md) and return a compact verdict line; they never edit code. Only use the Workflow tool if the user explicitly opts into multi-agent orchestration — otherwise use parallel Agent calls.

Fan-out tips that paid off:

  • Group advisories into root-cause clusters and give each subagent its sibling list ("likely same sink as X; cross-check, validate only yours"). This is what surfaces duplicates and the systemic patterns — a subagent blind to its siblings can't dedupe.
  • Runtimes are very uneven (single deep history-trace took ~30 min vs ~3 min typical, and a subagent may spawn its own sub-agent for git archaeology). Tell agents to prioritize the current-main verdict and treat exhaustive history as secondary.
  • Don't poll with sleep (the harness blocks it). To wait for all reports, arm a background until [ "$(ls .security-triage/reports/*.md | wc -l)" -ge N ]; do sleep 3; done and let it notify on completion; collect the streamed verdict notifications meanwhile.

Activepieces sink patterns — "looks guarded but isn't" (grep these first)

High-yield recurring footguns in this codebase. Each surfaced as a confirmed advisory; a repo-wide grep for them catches the systemic cluster, not just the reported instance:

  • securityAccess.project(..., undefined, ...) — passing undefined as the required permission. grantAccess() returns true on a nil permission (rbac-service.ts), so the route collapses to membership-only — any project member (incl. VIEWER) passes. Confirm a dedicated permission exists and is deliberately withheld (the route is a bug) vs. genuinely membership- only by design (e.g. piece-metadata reads). Compare against a sibling module that wires the permission correctly (git-sync does).
  • securityAccess.publicPlatform([PrincipalType.USER]) on a state-changing or platform-wide route where platformAdminOnly is required — publicPlatform sets adminOnly:false, so the admin assertion never runs; any authenticated member reaches it. Compare to audit-events / api-keys / signing-keys / global-connections, which all use platformAdminOnly.
  • TypeORM .where() called twice — the second .where() replaces the first (use .andWhere). A projectId filter followed by .where({ appName }) silently drops tenant isolation.
  • projectIds @> '[]'::jsonb — empty-array JSONB containment is TRUE for every row in Postgres, so an absent/empty projectIds collapses the tenant filter to match everything.
  • request.projectId trusted under PLATFORM auth — it is only populated for AuthorizationType.PROJECT; under publicPlatform/PLATFORM it is undefined, and downstream code that reads it as a scope key then "fails open".
  • Websocket handlers with no per-event RBAC — the dispatcher validates only the handshake projectId; individual addListener handlers trust client-supplied resourceId/flowVersionId with no per-event permission or resource→project ownership check.
  • Egress not via safeHttp — AI-provider/piece outbound calls using pieces-common httpClient (native fetch/undici) instead of safeHttp; user-controlled baseUrl/host/ resourceName interpolated into a URL = SSRF. The in-process dns/socket guards are opt-in (non-default network mode) and best-effort — verify they actually classify the transport in use.
  • NODE_TLS_REJECT_UNAUTHORIZED='0' set anywhere — a process-wide, persistent TLS-verification kill (its own CWE-295 defect regardless of the reported vuln).
  • window.opener.postMessage(payload, '*') — wildcard targetOrigin leaks the payload (OAuth code) to any opener origin; receive-side origin checks do NOT mitigate the send side.
  • Identity matched by email alone — federated login (getIdentityByEmail) / a global user_identity keyed only on email, with no provider/sub/NameID binding = cross-tenant account takeover surface.

Step 4 — Score + SLA (deterministic)

npm run security:sla -- --source advisory                # excludes resolved by default
npm run security:sla -- --source advisory --state triage  # un-assessed backlog only
npm run security:sla -- --source advisory --state all     # include closed/published

Reads .security-triage/advisories.json, computes deadlines + status, writes .security-triage/sla.json + .security-triage/dashboard.md. By default it drops closed/published/withdrawn advisories; --state <csv> overrides. SLA clock starts at the advisory creation date:

SeverityRemediate withinDUE_SOON threshold
Critical7 days≤ 2 days left
High30 days≤ 7 days left
Medium90 days≤ 14 days left
Lowbest-effort (no hard deadline)

Status buckets: BREACHEDDUE_SOONON_TRACKBEST_EFFORTNEEDS_TRIAGE (unscored). Sorted most-urgent-first.

Step 5 — Report

Write a per-advisory review-ready markdown file under .security-triage/reports/<ghsa>.md: scope verdict, validity verdict + file:line evidence, the sink, PoC sketch, reported vs assessed severity (note when deep-validation changes it; the SLA dashboard buckets by the GitHub-reported severity), SLA deadline + status, recommendation.

Then write the consolidated .security-triage/TRIAGE-SUMMARY.md — this is the artifact the user actually reviews. It must include:

  • Verdict tally (how many CONFIRMED_EXPLOITABLE / THEORETICAL / ALREADY_MITIGATED / FALSE_POSITIVE / OUT_OF_SCOPE).
  • A table grouped by severity/SLA status: GHSA, SLA status, verdict, sink file:line, one-line note.
  • Duplicates called out (same sink/root cause → fix once).
  • Systemic patterns — where one fix covers several advisories (e.g. a shared broken guard).
  • Severity downgrades/upgrades from deep-validation.

Present the SLA dashboard + the summary's headline verdicts in chat. Lead with the count of CONFIRMED_EXPLOITABLE & unpatched-on-main, NOT the raw BREACHED count — the dashboard buckets by GitHub-reported severity and lumps in already-fixed advisories, so "24 BREACHED" badly overstates the real backlog (a run can have 24 BREACHED but only ~15 actually actionable). State explicitly that BREACHED is reported-severity-based and includes mitigated items, then give the actionable list sorted by urgency.

Step 6 — Fix on approval (never before the user approves)

After the user picks which advisories to fix, follow the playbook's private flow:

  • Draft the patch + a regression test that fails before / passes after.
  • Stage it on a security/<ghsa-id> branch (private fork), patch-bump only — never bundle features. Do not open a public PR before the embargo.

Closing / responding to a reporter

The repository-security-advisory REST API exposes state (closeable via PATCH /repos/activepieces/activepieces/security-advisories/<ghsa-id> -f state=closed) but no comments endpoint — the conversation thread is UI-only. To respond with a reason, draft the message, have the user paste it into the advisory page, THEN close (so the reporter sees the reason, not a bare close notification).

Output recap

Everything lands in .security-triage/ (gitignored): advisories.json, sla.json, dashboard.md, and one report per advisory. Nothing private is ever committed.