diff --git a/cmd/helios/main.go b/cmd/helios/main.go index 2059db2..5a388c3 100644 --- a/cmd/helios/main.go +++ b/cmd/helios/main.go @@ -130,18 +130,16 @@ func main() { "username_set", cfg.XUsername != "", ) xFeedRunner := &xfeed.Runner{ - Enabled: cfg.XFeedEnabled, - SkillDir: cfg.XFeedSkillDir, - IncludeAttackerCA: cfg.XFeedIncludeAttackerCA, - CardEnabled: cfg.XFeedCardEnabled, - CardPythonBin: cfg.XFeedCardPythonBin, - CardTimeout: time.Duration(cfg.XFeedCardTimeoutSeconds) * time.Second, - Mentions: mentionIndex, + Enabled: cfg.XFeedEnabled, + SkillDir: cfg.XFeedSkillDir, + CardEnabled: cfg.XFeedCardEnabled, + CardPythonBin: cfg.XFeedCardPythonBin, + CardTimeout: time.Duration(cfg.XFeedCardTimeoutSeconds) * time.Second, + Mentions: mentionIndex, } logger.Info("x feed runner configured", "enabled", xFeedRunner.Configured(), "skill_dir", cfg.XFeedSkillDir, - "include_attacker_ca", cfg.XFeedIncludeAttackerCA, "card_enabled", cfg.XFeedCardEnabled, "card_python_bin", cfg.XFeedCardPythonBin, "telegram_publish_enabled", cfg.TelegramPublishEnabled, diff --git a/deploy/README.md b/deploy/README.md index d3c1ba0..3c216d2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -391,10 +391,12 @@ HELIOS_X_FEED_CARD_TIMEOUT_SECONDS=20 TELEGRAM_PUBLISH_ENABLED=true ``` -The flow is GitHub publish -> x-feed draft -> X main post/reply -> Telegram +The flow is GitHub publish -> x-feed draft -> X rich main post/Tx-only reply -> Telegram publish. Telegram gets the same main X body with `GitHub:` and `X:` links appended. When the card switch is enabled, Backlight also generates a public-safe exploit-flow card from the x-feed brief and attaches the PNG to the main X post. +Before any live media upload or post, Backlight verifies that the refreshed OAuth +token belongs to `X_ACCOUNT_USERNAME` and aborts on a mismatch. Install `requirements-x-feed.txt` in the configured Python environment to produce the PNG, for example a dedicated venv under `/srv/backlight/data/x-feed-venv` with `HELIOS_X_FEED_CARD_PYTHON_BIN=/srv/backlight/data/x-feed-venv/bin/python`. diff --git a/deploy/env/backlight.env.example b/deploy/env/backlight.env.example index e29d9ea..a66a883 100644 --- a/deploy/env/backlight.env.example +++ b/deploy/env/backlight.env.example @@ -33,7 +33,6 @@ GH_TOKEN= # X_DRY_RUN=true # HELIOS_X_FEED_ENABLED=false # HELIOS_X_FEED_SKILL_DIR=skills/x-feed -# HELIOS_X_FEED_INCLUDE_ATTACKER_CA=false # HELIOS_X_FEED_CARD_ENABLED=true # HELIOS_X_FEED_CARD_PYTHON_BIN=python3 # HELIOS_X_FEED_CARD_TIMEOUT_SECONDS=20 diff --git a/docs/operations.md b/docs/operations.md index 1d79f3c..34cb51e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -86,11 +86,10 @@ precedence; `.env.local` can override `.env`. | `X_REFRESH_TOKEN_FILE` | no | empty | optional 0600 JSON/raw-token file used before `X_REFRESH_TOKEN` and updated when X rotates the refresh token | | `X_POST_TEMPLATE_PATH` | no | embedded default | optional Markdown `text/template` file for the X verified-incident post body | | `X_API_BASE` | no | `https://api.x.com` | override for tests or proxies | -| `X_ACCOUNT_USERNAME` | no | empty | optional username used to build the public `post_url`; omit to use `https://x.com/i/web/status/{id}` | +| `X_ACCOUNT_USERNAME` | recommended when X publish enabled and dry-run false | empty | expected publishing username; Backlight verifies `/2/users/me` matches it before media upload or posting and uses it to build the public `post_url` | | `X_DRY_RUN` | no | `true` | when true, records the generated X post text as `x_publish` without calling X | | `HELIOS_X_FEED_ENABLED` | no | `false` | enables the structured x-feed draft path after successful GitHub publish | | `HELIOS_X_FEED_SKILL_DIR` | no | `skills/x-feed` | vendored x-feed skill directory containing `incident-post-format.md` | -| `HELIOS_X_FEED_INCLUDE_ATTACKER_CA` | no | `false` | opt-in switch for attacker CA details in the X reply | | `HELIOS_X_FEED_CARD_ENABLED` | no | `true` | runs the vendored `exploit-flow-card` renderer during x-feed draft generation | | `HELIOS_X_FEED_CARD_PYTHON_BIN` | no | `python3` | Python executable used for the exploit-flow-card renderer | | `HELIOS_X_FEED_CARD_TIMEOUT_SECONDS` | no | `20` | timeout for the exploit-flow-card renderer; failures are recorded but do not block text publishing | @@ -161,10 +160,13 @@ lumoskit child process per ADR-0018 in the `lumoskit` repo. Backlight can persist rotated refresh tokens with `0600` permissions. SQLite events record only `refresh_returned` / `refresh_token_updated`; token material is never stored in case events. +- **X account preflight.** Set `X_ACCOUNT_USERNAME=BackwardLabs` in live mode. + After token refresh and before any media upload or post, Backlight calls + `/2/users/me` and aborts if the authenticated username does not match. - **x-feed publish sequence.** When `HELIOS_X_FEED_ENABLED=true`, Backlight waits for GitHub artifact publish to succeed, drafts `x-feed-main-post.txt`, `x-feed-reply-post.txt`, `x-feed-telegram-post.txt`, and `x-feed-status.json`, - then publishes the X main post and reply. `ready_to_publish=false` blocks X + then publishes the rich X main post and a Tx-only reply. `ready_to_publish=false` blocks X posting when required public URLs or the vendored format file are missing. - **Exploit-flow card generation.** With `HELIOS_X_FEED_CARD_ENABLED=true`, Backlight writes a public-safe `x-feed-card-brief.md`, runs the vendored @@ -172,8 +174,8 @@ lumoskit child process per ADR-0018 in the `lumoskit` repo. `x-feed-status.json`. The renderer always attempts SVG output; PNG output requires `requirements-x-feed.txt` installed in `HELIOS_X_FEED_CARD_PYTHON_BIN`'s environment. -- **X media upload.** If the x-feed draft supplies `image_path`, Backlight uploads - it through X API v2 media upload before creating the main post, then attaches +- **X media upload.** If the x-feed draft supplies `image_path`, Backlight sends + it as a base64 JSON payload to X API v2 media upload before creating the main post, then attaches the returned media id to `POST /2/tweets`. The exploit-flow card PNG becomes that `image_path` when rendering succeeds. If card rendering fails or only SVG is produced, Backlight records `card_error` and still publishes the text thread diff --git a/internal/config/config.go b/internal/config/config.go index b19c69a..b1f217e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,7 +41,6 @@ type Config struct { XDryRun bool XFeedEnabled bool XFeedSkillDir string - XFeedIncludeAttackerCA bool XFeedCardEnabled bool XFeedCardPythonBin string XFeedCardTimeoutSeconds int @@ -107,7 +106,6 @@ func Load() (*Config, error) { XDryRun: envBool("X_DRY_RUN", true), XFeedEnabled: envBool("HELIOS_X_FEED_ENABLED", false), XFeedSkillDir: envDefault("HELIOS_X_FEED_SKILL_DIR", "skills/x-feed"), - XFeedIncludeAttackerCA: envBool("HELIOS_X_FEED_INCLUDE_ATTACKER_CA", false), XFeedCardEnabled: envBool("HELIOS_X_FEED_CARD_ENABLED", true), XFeedCardPythonBin: envDefault("HELIOS_X_FEED_CARD_PYTHON_BIN", "python3"), XFeedCardTimeoutSeconds: envInt("HELIOS_X_FEED_CARD_TIMEOUT_SECONDS", 20), diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index fa06034..c39c2ef 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -278,6 +278,10 @@ func TestWorkerXFeedPublishesXThreadThenTelegram(t *testing.T) { "expires_in": 7200, "token_type": "bearer", }) + case "/2/users/me": + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]string{"id": "account-id", "username": "BackwardLabs"}, + }) case "/2/tweets": xPostCount++ var body map[string]any @@ -287,14 +291,26 @@ func TestWorkerXFeedPublishesXThreadThenTelegram(t *testing.T) { switch xPostCount { case 1: text := body["text"].(string) - if !strings.Contains(text, "[Backlight Initial Analysis]") || !strings.Contains(text, "Need more detail? Check our repo and analysis thread below โ†“ ๐Ÿงต") { + for _, want := range []string{ + "๐Ÿšจ yETH โ€” Under review", + "Key info", + "TL;DR", + "Why it matters", + "Builder takeaway", + } { + if !strings.Contains(text, want) { + t.Fatalf("main X body missing %q: %q", want, text) + } + } + if strings.Contains(text, "[Backlight") || strings.Contains(text, "Tx: ") { t.Fatalf("main X body = %q", text) } _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]string{"id": "main-id", "text": text}}) case 2: text := body["text"].(string) reply, ok := body["reply"].(map[string]any) - if !ok || reply["in_reply_to_tweet_id"] != "main-id" || !strings.Contains(text, "1/ Artifacts + analysis ๐Ÿงพ") { + want := "Tx: https://etherscan.io/tx/0x" + strings.Repeat("8", 64) + if !ok || reply["in_reply_to_tweet_id"] != "main-id" || text != want { t.Fatalf("reply X body = %#v", body) } _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]string{"id": "reply-id", "text": text}}) diff --git a/internal/xfeed/runner.go b/internal/xfeed/runner.go index a641faf..32b98d9 100644 --- a/internal/xfeed/runner.go +++ b/internal/xfeed/runner.go @@ -21,18 +21,17 @@ import ( ) const ( - formatVersion = "backlight_x_feed_incident_v1" + formatVersion = "backlight_x_feed_incident_v2" defaultIncidentFormat = "skills/draft-x-exploit-thread/references/incident-post-format.md" defaultCardScript = "skills/exploit-flow-card/scripts/render_card.py" ) type Runner struct { - Enabled bool - SkillDir string - IncludeAttackerCA bool - CardEnabled bool - CardPythonBin string - CardTimeout time.Duration + Enabled bool + SkillDir string + CardEnabled bool + CardPythonBin string + CardTimeout time.Duration // Mentions resolves the victim protocol's official @handle for the post // headline. Nil = disabled (plain protocol name). Mentions *mention.Index @@ -101,6 +100,11 @@ type incidentFacts struct { CardError string ImpactUSD float64 ReproducedUSD float64 + AffectedAssets []string + ImpactCaveat string + Invariant string + Severity string + Confidence string Card cardFacts ReadyToPublish bool Blockers []string @@ -141,7 +145,7 @@ func (r *Runner) Run(ctx context.Context, c Case) (*Result, error) { facts := buildFacts(c) r.applyExploitFlowCard(ctx, c, &facts) - result := renderResult(r, c, facts) + result := renderResult(r, facts) if _, err := os.Stat(result.SourceFormat); err != nil { result.ReadyToPublish = false result.Blockers = append(result.Blockers, "x_feed_format_missing") @@ -207,6 +211,9 @@ func buildFacts(c Case) incidentFacts { githubURL := firstText(c.GitHubURL, reportURL) blockers := publishBlockers(reportURL, githubURL) + if explorer == "" { + blockers = append(blockers, "explorer_url_missing") + } if !outcome.ShouldPublishX(outcome.Result{ Outcome: c.Outcome, PublishTier: c.PublishTier, @@ -215,6 +222,7 @@ func buildFacts(c Case) incidentFacts { }) { blockers = append(blockers, "x_publish_ineligible") } + card := buildCardFacts(protocol, summary, reportJSON, report, impact, impactUSD, reproducedUSD) return incidentFacts{ Protocol: protocol, Chain: chain, @@ -233,65 +241,26 @@ func buildFacts(c Case) incidentFacts { ImageMode: "none", ImpactUSD: impactUSD, ReproducedUSD: reproducedUSD, - Card: buildCardFacts(protocol, summary, reportJSON, report, impact, impactUSD, reproducedUSD), + AffectedAssets: affectedAssetSymbols(summary, reportJSON), + ImpactCaveat: impactCaveat(summary, reportJSON, report), + Invariant: publicInvariant(reportJSON, report), + Severity: cleanCardText(firstText(jsonPathString(reportJSON, "vulnerability", "severity"), markdownField(report, "Severity"), "under review")), + Confidence: cleanCardText(firstText(jsonPathString(reportJSON, "vulnerability", "confidence"), jsonPathString(reportJSON, "root_cause_confidence"), markdownField(report, "Confidence"), "under review")), + Card: card, ReadyToPublish: len(blockers) == 0, Blockers: blockers, } } -func renderResult(r *Runner, c Case, f incidentFacts) *Result { +func renderResult(r *Runner, f incidentFacts) *Result { for len(f.WhatHappened) < 3 { f.WhatHappened = append(f.WhatHappened, "Details remain under review.") } - main := strings.TrimSpace(fmt.Sprintf(`[Backlight %s] - -๐Ÿšจ %s exploit on %s -Tx: %s - -๐Ÿ•’ Occurred: %s -๐Ÿ’ฅ Impact: %s - -๐Ÿ”Ž Root cause: -%s - -What happened: -%s -%s -%s - -Need more detail? Check our repo and analysis thread below โ†“ ๐Ÿงต`, - f.StatusLabel, - mention.FormatTag(r.Mentions, f.Protocol), - f.Chain, - f.TxHash, - f.Occurred, - f.Impact, - f.RootCause, - f.WhatHappened[0], - f.WhatHappened[1], - f.WhatHappened[2], - )) - report := fallback(f.ReportURL, "pending") - poc := pocLine(f.PoCURL, c) - replyParts := []string{ - "1/ Artifacts + analysis ๐Ÿงพ", - "", - "Artifacts:", - "- Report: " + report, - "- PoC: " + poc, - "", - "Analysis:", - f.Analysis, - } + main := renderRichMainPost(r, f) + reply := "" if f.ExplorerURL != "" { - replyParts = append(replyParts, "", "Explorer:", f.ExplorerURL) + reply = "Tx: " + f.ExplorerURL } - if r != nil && r.IncludeAttackerCA { - // Reserved for the original x-feed reference format. It stays opt-in - // because the current Backlight public thread omits attacker CA details. - replyParts = append(replyParts, "", "Attacker CA:", "- under review") - } - reply := strings.TrimSpace(strings.Join(replyParts, "\n")) telegram := main if f.GitHubURL != "" { telegram += "\n\nGitHub:\n" + f.GitHubURL @@ -324,6 +293,186 @@ Need more detail? Check our repo and analysis thread below โ†“ ๐Ÿงต`, } } +func renderRichMainPost(r *Runner, f incidentFacts) string { + protocol := f.Protocol + if r != nil { + protocol = mention.FormatTag(r.Mentions, f.Protocol) + } + loss := estimatedLossLabel(f) + component := firstText(f.Card.Victim, f.Card.VulnerableContract, "affected protocol component") + assets := "under review" + if len(f.AffectedAssets) > 0 { + assets = strings.Join(f.AffectedAssets, ", ") + } + mechanism := strings.TrimSpace(strings.Join(f.WhatHappened[:2], " ")) + outcomeLine := strings.TrimSpace(f.WhatHappened[2]) + + var b strings.Builder + fmt.Fprintf(&b, "๐Ÿšจ %s โ€” %s\n\n", protocol, incidentHeadline(f)) + fmt.Fprintf(&b, "%s\n\n", incidentLead(protocol, f, loss)) + fmt.Fprintf(&b, "Key info\n") + fmt.Fprintf(&b, "โ€ข Chain: %s\n", f.Chain) + fmt.Fprintf(&b, "โ€ข Occurred: %s\n", f.Occurred) + fmt.Fprintf(&b, "โ€ข Estimated loss: %s\n", loss) + fmt.Fprintf(&b, "โ€ข Affected component: %s\n", component) + fmt.Fprintf(&b, "โ€ข Affected assets: %s\n\n", assets) + fmt.Fprintf(&b, "TL;DR\n%s\n\n%s\n\n%s\n\n", trimSentence(f.RootCause), trimSentence(mechanism), trimSentence(outcomeLine)) + fmt.Fprintf(&b, "Why it matters\n%s\n\n", whyItMatters(f)) + fmt.Fprintf(&b, "Builder takeaway\n%s", builderTakeaway(f)) + if caveat := strings.TrimSpace(f.ImpactCaveat); caveat != "" { + fmt.Fprintf(&b, "\n\n%s", trimSentence(caveat)) + } + return strings.TrimSpace(b.String()) +} + +func incidentHeadline(f incidentFacts) string { + hay := strings.ToLower(strings.Join([]string{f.AttackType, f.RootCause, f.Invariant, f.Card.Vulnerability, f.Card.VulnerablePath}, " ")) + switch { + case strings.Contains(hay, "pair") && strings.Contains(hay, "burn"): + return "LP Reserve Burn" + case strings.Contains(hay, "skim") && strings.Contains(hay, "sync"): + return "AMM Reserve Manipulation" + case strings.Contains(hay, "negative margin") || strings.Contains(hay, "under-collateralized"): + return "Unsafe Margin Withdrawal" + case strings.Contains(hay, "referral") && strings.Contains(hay, "price"): + return "Redeemable Supply Mispricing" + case strings.Contains(hay, "reward") && (strings.Contains(hay, "repeat") || strings.Contains(hay, "duplicate")): + return "Repeated Reward Claim" + case strings.Contains(hay, "authorization") || strings.Contains(hay, "permission"): + return "Authorization Bypass" + case strings.Contains(hay, "pricing") || strings.Contains(hay, "accounting") || strings.Contains(hay, "settlement"): + return "Settlement Accounting Exploit" + default: + return titleFromSlug(truncateText(firstText(f.Card.Vulnerability, f.AttackType, "Incident Analysis"), 72)) + } +} + +func incidentLead(protocol string, f incidentFacts, loss string) string { + date := longIncidentDate(f.Occurred) + prefix := protocol + if date != "" { + prefix = "On " + date + ", " + protocol + } + if f.ImpactCaveat != "" && loss != "under review" { + return fmt.Sprintf("%s was exploited on %s, causing at least %s in priced losses.", prefix, f.Chain, strings.TrimSuffix(loss, "+")) + } + if loss == "under review" { + return fmt.Sprintf("%s was exploited on %s. The loss remains under review.", prefix, f.Chain) + } + return fmt.Sprintf("%s was exploited on %s, with an estimated loss of %s.", prefix, f.Chain, loss) +} + +func longIncidentDate(occurred string) string { + fields := strings.Fields(occurred) + if len(fields) == 0 { + return "" + } + parsed, err := time.Parse("2006-01-02", fields[0]) + if err != nil { + return "" + } + return parsed.Format("January 2, 2006") +} + +func estimatedLossLabel(f incidentFacts) string { + if f.ImpactUSD > 0 { + label := detailedUSD(f.ImpactUSD) + if f.ImpactCaveat != "" { + label += "+" + } + return label + } + value := strings.TrimSpace(f.Impact) + value = strings.TrimPrefix(value, "approximately ") + value = strings.TrimPrefix(value, "~") + if value == "" { + return "under review" + } + if match := regexp.MustCompile(`^([0-9][0-9,]*(?:\.[0-9]+)?)\s+(USDT|USDC|DAI)$`).FindStringSubmatch(strings.ToUpper(value)); len(match) == 3 { + if amount, err := strconv.ParseFloat(strings.ReplaceAll(match[1], ",", ""), 64); err == nil { + return detailedUSD(amount) + } + } + return value +} + +func detailedUSD(value float64) string { + abs := math.Abs(value) + switch { + case abs >= 1_000_000_000: + return compactUSDScale(value/1_000_000_000, "B") + case abs >= 1_000_000: + return compactUSDScale(value/1_000_000, "M") + case abs >= 1_000: + return compactUSDScale(value/1_000, "K") + default: + return fmt.Sprintf("$%.0f", value) + } +} + +func compactUSDScale(value float64, suffix string) string { + number := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", value), "0"), ".") + return "$" + number + suffix +} + +func whyItMatters(f incidentFacts) string { + hay := strings.ToLower(strings.Join([]string{f.RootCause, f.Invariant, f.Card.Vulnerability, f.Card.VulnerablePath}, " ")) + switch { + case strings.Contains(hay, "pair") && strings.Contains(hay, "burn"): + return "AMMs assume a token transfer cannot rewrite the pool's existing inventory as if it were trader input. Breaking that boundary turns transfer-side token logic into a reserve-manipulation primitive." + case strings.Contains(hay, "negative margin") || strings.Contains(hay, "withdrawable equity"): + return "Margin engines must derive withdrawals from current collateral, position, PnL, funding, and fee state. Accepting stale or attacker-shaped accounting can turn a position update into direct asset release." + case strings.Contains(hay, "redeemable supply") || strings.Contains(hay, "liveprice"): + return "Redemption pricing is only safe when reserve value and every redeemable balance are updated on the same accounting basis. Temporary liquidity must not inflate the price of newly redeemable supply." + case f.Invariant != "": + return "The incident broke a core safety boundary: " + trimSentence(lowerLeading(f.Invariant)) + default: + return "The incident shows how an internal accounting assumption can become an asset-extraction path when state validation and value transfer are not kept on the same boundary." + } +} + +func builderTakeaway(f incidentFacts) string { + hay := strings.ToLower(strings.Join([]string{f.RootCause, f.Invariant, f.Card.Vulnerability, f.Card.VulnerablePath}, " ")) + switch { + case strings.Contains(hay, "pair") && strings.Contains(hay, "burn"): + return "A token should never burn from or resynchronize an AMM pair's existing inventory during transfer. Bound fees and burns to the sender's input without mutating LP reserves mid-transfer." + case strings.Contains(hay, "negative margin") || strings.Contains(hay, "withdrawable equity"): + return "Recompute withdrawable equity from current state immediately before releasing funds, and reject any withdrawal that would leave the account below its solvency requirement." + case strings.Contains(hay, "redeemable supply") || strings.Contains(hay, "liveprice"): + return "Update redeemable supply and reserve accounting before calculating a redemption price, and prevent same-transaction liquidity from pricing balances created in that transaction." + case f.Invariant != "": + return "Enforce the invariant against current state before value moves: " + trimSentence(lowerLeading(f.Invariant)) + default: + return "Validate the complete post-action state before releasing assets, and keep attacker-controlled intermediate state out of pricing, solvency, and authorization decisions." + } +} + +func publicInvariant(reportJSON []byte, report string) string { + explicit := firstText( + jsonPathString(reportJSON, "vulnerability", "violated_invariant"), + markdownField(report, "Violated invariant"), + ) + if explicit != "" { + return sanitizeSentence(explicit) + } + hay := strings.ToLower(strings.Join([]string{ + jsonPathString(reportJSON, "vulnerability", "title"), + jsonPathString(reportJSON, "vulnerability", "root_cause"), + jsonPathString(reportJSON, "attack_summary", "entry_function"), + jsonPathString(reportJSON, "attack_summary", "public_entrypoint_called_per_iteration"), + }, " ")) + switch { + case strings.Contains(hay, "pair") && strings.Contains(hay, "burn"): + return "Token transfers must not debit or resynchronize an AMM pair's existing inventory before crediting the incoming transfer." + case strings.Contains(hay, "negative margin") || strings.Contains(hay, "under-collateralized"): + return "A margin withdrawal must not exceed verified withdrawable equity after current position, PnL, funding, fee, and solvency checks." + case strings.Contains(hay, "price") || strings.Contains(hay, "accounting") || strings.Contains(hay, "settlement"): + return "The state used to price and validate a payout must include every balance that is redeemable through that payout path." + default: + return "The state used to authorize and price value release must remain consistent with the state changed by the action." + } +} + func (r *Runner) applyExploitFlowCard(ctx context.Context, c Case, f *incidentFacts) { if r == nil || !r.CardEnabled || f == nil { return @@ -357,10 +506,9 @@ func (r *Runner) applyExploitFlowCard(ctx context.Context, c Case, f *incidentFa cardCtx, pythonBin, script, - filepath.Join(c.OutputRoot, "report_bundle"), + briefPath, "--out-dir", outDir, "--basename", "exploit-flow-card", - "--title", fmt.Sprintf("%s Exploit Flow", f.Protocol), ) cmd.Env = append(os.Environ(), "PYTHONDONTWRITEBYTECODE=1") output, err := cmd.CombinedOutput() @@ -406,98 +554,73 @@ func writeCardBrief(outputRoot string, c Case, f incidentFacts) (string, error) return "", errors.New("output_root is required for card brief") } path := filepath.Join(outputRoot, "x-feed-card-brief.md") - reproduced := f.ReproducedUSD - if reproduced <= 0 { - reproduced = f.ImpactUSD + steps := append([]string(nil), f.Card.FlowSteps...) + if len(steps) == 0 { + steps = append(steps, f.WhatHappened...) } - proofKind := "proof under review" - if c.PoCState == outcome.PoCStateEconomic { - proofKind = "economic_proof" + for len(steps) < 3 { + steps = append(steps, "Details remain under review.") } - pocStatus := "under review" - if c.PoCState == outcome.PoCStateEconomic || c.Outcome == outcome.OutcomeVerified || c.Outcome == outcome.OutcomePartial { - pocStatus = "verified" + for i := range steps { + steps[i] = stripStepNumber(steps[i]) } - mechanism := append([]string(nil), f.WhatHappened...) - if len(f.Card.FlowSteps) > 0 { - mechanism = append([]string(nil), f.Card.FlowSteps...) + inShort := strings.TrimSpace(strings.Join(f.WhatHappened[:min(len(f.WhatHappened), 2)], " ")) + disclosure := "approved-for-draft" + if f.ReadyToPublish { + disclosure = "publishable" } - for len(mechanism) < 3 { - mechanism = append(mechanism, "Details remain under review.") + assets := "under review" + if len(f.AffectedAssets) > 0 { + assets = strings.Join(f.AffectedAssets, ", ") } var b strings.Builder - fmt.Fprintf(&b, "# %s Exploit Flow\n\n", f.Protocol) + fmt.Fprintf(&b, "# Public Card Packet โ€” %s Incident\n\n", f.Protocol) + fmt.Fprintf(&b, "- **Disclosure status**: %s\n", disclosure) fmt.Fprintf(&b, "- **Protocol**: %s\n", f.Protocol) + fmt.Fprintf(&b, "- **Headline**: %s โ€” %s\n", f.Protocol, incidentHeadline(f)) fmt.Fprintf(&b, "- **Chain**: %s\n", f.Chain) - fmt.Fprintf(&b, "- **Estimated loss**: %s\n", moneyForCard(f.ImpactUSD, f.Impact)) - fmt.Fprintf(&b, "- **Attacker gain reproduced**: %s\n", moneyForCard(reproduced, "unknown")) - if f.Card.Impact != "" { - fmt.Fprintf(&b, "- **Impact card**: %s\n", f.Card.Impact) - } - if f.Card.AttackerGain != "" { - fmt.Fprintf(&b, "- **Attacker gain card**: %s\n", f.Card.AttackerGain) - } - fmt.Fprintf(&b, "- **RCA status**: %s\n", cardRCAStatus(c, f)) - if f.Card.VulnerablePath != "" { - fmt.Fprintf(&b, "- **Vulnerable path**: %s\n", f.Card.VulnerablePath) - } - if f.Card.VulnerableContract != "" { - fmt.Fprintf(&b, "- **Vulnerable contract**: %s\n", f.Card.VulnerableContract) - } - if f.Card.Vulnerability != "" { - fmt.Fprintf(&b, "- **Vulnerability**: %s\n", f.Card.Vulnerability) - } - if f.Card.ExploitResult != "" { - fmt.Fprintf(&b, "- **Exploit result**: %s\n", f.Card.ExploitResult) - } - if f.Card.Victim != "" { - fmt.Fprintf(&b, "- **Victim**: %s\n", f.Card.Victim) - } - if f.Card.LoopLabel != "" { - fmt.Fprintf(&b, "- **Loop label**: %s\n", f.Card.LoopLabel) - } - for i, step := range f.Card.FlowSteps { + fmt.Fprintf(&b, "- **Occurred**: %s\n", f.Occurred) + fmt.Fprintf(&b, "- **Finding**: %s\n", trimSentence(f.RootCause)) + fmt.Fprintf(&b, "- **In short**: %s\n", trimSentence(inShort)) + fmt.Fprintf(&b, "- **Severity**: %s\n", fallback(f.Severity, "under review")) + fmt.Fprintf(&b, "- **Confidence**: %s\n", fallback(f.Confidence, "under review")) + fmt.Fprintf(&b, "- **RCA status**: %s\n", publicCardRCAStatus(c, f)) + fmt.Fprintf(&b, "- **Vulnerable contract**: %s\n", firstText(f.Card.VulnerableContract, "Affected protocol contract")) + fmt.Fprintf(&b, "- **Vulnerable path**: %s\n", firstText(f.Card.VulnerablePath, "affected accounting path")) + fmt.Fprintf(&b, "- **Victim**: %s\n", firstText(f.Card.Victim, "Affected protocol component")) + fmt.Fprintf(&b, "- **Impact card**: %s\n", estimatedLossLabel(f)) + if f.ImpactCaveat != "" { + fmt.Fprintf(&b, "- **Impact note**: %s\n", strings.TrimSuffix(f.ImpactCaveat, ".")) + } + fmt.Fprintf(&b, "- **Affected assets**: %s\n", assets) + for i, step := range steps { if i >= 4 { break } fmt.Fprintf(&b, "- **Flow step %d**: %s\n", i+1, step) } - fmt.Fprintf(&b, "- **PoC status**: %s\n", pocStatus) - fmt.Fprintf(&b, "- **Proof kind**: %s\n", proofKind) - fmt.Fprintf(&b, "- **Tx**: %s\n\n", f.TxHash) - fmt.Fprintf(&b, "## Root Cause\n\n%s\n\n", firstText(f.Card.Subtitle, f.RootCause)) - fmt.Fprintf(&b, "Mechanism:\n") - cardMechanism := append([]string(nil), f.Card.Mechanism...) - if len(cardMechanism) == 0 { - cardMechanism = mechanism - } - for len(cardMechanism) < 3 { - cardMechanism = append(cardMechanism, "Details remain under review.") - } - for _, line := range cardMechanism[:3] { - fmt.Fprintf(&b, "- %s\n", line) - } - fmt.Fprintf(&b, "\nKey evidence:\n") - if len(f.Card.KeyEvidence) > 0 { - for _, line := range f.Card.KeyEvidence { - fmt.Fprintf(&b, "- %s\n", strings.TrimSuffix(line, ".")+".") - } - } else { - fmt.Fprintf(&b, "- Impact scale: %s.\n", f.Impact) - } - if f.ReproducedUSD > 0 { - fmt.Fprintf(&b, "- Economic reproduction matched approximately %s.\n", moneyForCard(f.ReproducedUSD, "unknown")) - } - if f.ExplorerURL != "" { - fmt.Fprintf(&b, "- Explorer: %s.\n", f.ExplorerURL) - } + fmt.Fprintf(&b, "- **Violated invariant**: %s\n", f.Invariant) if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { return "", err } return path, nil } +func stripStepNumber(value string) string { + return strings.TrimSpace(regexp.MustCompile(`^\s*\d+\s*[.)-]\s*`).ReplaceAllString(value, "")) +} + +func publicCardRCAStatus(c Case, f incidentFacts) string { + if c.RCAState == outcome.RCAStateComplete && !strings.EqualFold(f.Confidence, "low") { + return "confirmed" + } + if c.Outcome == outcome.OutcomePartial || c.PublishTier == outcome.PublishTierEconomicIncompleteRCA || (c.RCAState != "" && c.RCAState != outcome.RCAStateComplete) { + return "partial" + } + return "unproven" +} + func buildCardFacts(protocol string, summary, reportJSON []byte, report, impact string, impactUSD, reproducedUSD float64) cardFacts { vulnerablePath := cleanCardText(firstText( jsonPathString(reportJSON, "attack_summary", "entry_function"), @@ -1363,6 +1486,105 @@ func reproducedUSDValue(summary []byte) float64 { return 0 } +func affectedAssetSymbols(summary, reportJSON []byte) []string { + out := make([]string, 0, 3) + add := func(raw string) { + symbol := strings.ToUpper(strings.TrimSpace(cleanCardText(raw))) + if symbol == "" || strings.EqualFold(symbol, "unknown") || containsValue(out, symbol) { + return + } + if !regexp.MustCompile(`^[A-Z0-9._-]{1,16}$`).MatchString(symbol) { + return + } + out = append(out, symbol) + } + add(jsonPathString(reportJSON, "impact", "attacker_profit_symbol")) + for _, path := range [][]string{ + {"economic_reproduction", "incident", "asset_legs"}, + {"economic_reproduction", "incident", "drain_legs"}, + {"economic_reproduction", "poc", "included_legs"}, + {"economic_reproduction", "poc", "gain_family_selection", "included_legs"}, + } { + arr, _ := jsonValue(summary, path...).([]any) + for _, item := range arr { + if m, ok := item.(map[string]any); ok { + add(fmt.Sprint(m["symbol"])) + } + if len(out) >= 3 { + return out[:3] + } + } + } + if losses, ok := jsonValue(reportJSON, "impact", "victim_losses").([]any); ok { + for _, item := range losses { + if m, ok := item.(map[string]any); ok { + add(fmt.Sprint(m["symbol"])) + } + if len(out) >= 3 { + return out[:3] + } + } + } + return out +} + +func impactCaveat(summary, reportJSON []byte, report string) string { + count := 0 + for _, path := range [][]string{ + {"economic_reproduction", "incident", "unpriced_leg_count"}, + {"economic_reproduction", "pricing", "unpriced_leg_count"}, + {"impact", "unpriced_leg_count"}, + } { + source := summary + if path[0] == "impact" { + source = reportJSON + } + if n := int(jsonPathNumber(source, path...)); n > count { + count = n + } + } + for _, path := range [][]string{ + {"economic_reproduction", "incident", "unpriced_legs"}, + {"economic_reproduction", "pricing", "unpriced_legs"}, + } { + if arr, ok := jsonValue(summary, path...).([]any); ok && len(arr) > count { + count = len(arr) + } + } + incomplete := jsonTruthy(jsonValue(summary, "economic_reproduction", "incident", "usd_incomplete")) || + jsonTruthy(jsonValue(summary, "economic_reproduction", "pricing", "usd_incomplete")) || + jsonTruthy(jsonValue(reportJSON, "impact", "usd_incomplete")) + field := strings.ToLower(markdownField(report, "USD incomplete")) + if strings.Contains(field, "true") || strings.Contains(field, "yes") || strings.Contains(field, "unpriced") { + incomplete = true + if count == 0 { + if match := regexp.MustCompile(`(\d+)\s+unpriced`).FindStringSubmatch(field); len(match) == 2 { + count, _ = strconv.Atoi(match[1]) + } + } + } + if !incomplete && count == 0 { + return "" + } + if count > 0 { + return fmt.Sprintf("The loss estimate is a lower bound because %d affected asset leg(s) could not be priced.", count) + } + return "The loss estimate is a lower bound because part of the affected asset movement could not be priced." +} + +func jsonTruthy(value any) bool { + switch v := value.(type) { + case bool: + return v + case string: + return strings.EqualFold(strings.TrimSpace(v), "true") || strings.EqualFold(strings.TrimSpace(v), "yes") + case float64: + return v > 0 + default: + return false + } +} + func moneyForCard(value float64, fallbackText string) string { if value > 0 { return fmt.Sprintf("$%.2f", value) @@ -1541,8 +1763,6 @@ func publicRootCause(protocol string, reportJSON []byte, c Case) string { func publicWhatHappened(protocol string, reportJSON, summary []byte) []string { category := strings.ToLower(attackType(reportJSON)) - proofVerified := strings.EqualFold(jsonPathString(summary, "poc", "status"), "verified") || - strings.EqualFold(jsonPathString(summary, "economic_reproduction", "status"), "pass") entryFunction := cleanPublicEntryFunction(jsonPathString(reportJSON, "attack_summary", "entry_function")) vulnerabilityText := strings.ToLower(strings.Join([]string{ jsonPathString(reportJSON, "vulnerability", "title"), @@ -1552,14 +1772,10 @@ func publicWhatHappened(protocol string, reportJSON, summary []byte) []string { if entryFunction != "" && (strings.Contains(vulnerabilityText, "negative margin") || strings.Contains(vulnerabilityText, "changeposition") || strings.Contains(vulnerabilityText, "margin withdrawal")) { - line3 := "The observed value movement matches the RCA direction, while source-level guard details remain limited." - if proofVerified { - line3 = "The economic PoC reproduced the observed value movement at incident scale." - } return []string{ fmt.Sprintf("The attacker reached %s on the vulnerable %s proxy path.", entryFunction, protocol), "The RCA points to an under-collateralized negative margin withdrawal being accepted after attacker-controlled position/accounting changes.", - line3, + "The resulting state transition released value from the affected margin account.", } } if strings.Contains(category, "pricing") || strings.Contains(category, "accounting") || strings.Contains(category, "settlement") { @@ -1568,24 +1784,16 @@ func publicWhatHappened(protocol string, reportJSON, summary []byte) []string { if asset != "value" { payouts = asset + " payouts" } - line3 := fmt.Sprintf("That mismatch let %s leave the affected reserve.", asset) - if proofVerified { - line3 = fmt.Sprintf("That mismatch let %s leave the market reserve, and the economic PoC reproduced the observed loss scale.", asset) - } return []string{ fmt.Sprintf("An attacker-controlled setup repeatedly interacted with a %s market path.", protocol), fmt.Sprintf("The protocol assumption under review is that settlement accounting would keep %s bounded by market reserves.", payouts), - line3, + fmt.Sprintf("That mismatch let %s leave the affected reserve.", asset), } } - line3 := "That mismatch created an extractable value movement." - if proofVerified { - line3 = "The economic PoC reproduced the observed value movement at incident scale." - } return []string{ fmt.Sprintf("An attacker-controlled setup interacted with a %s protocol path.", protocol), "The protocol assumption under review is that validation and accounting checks would keep the state bounded.", - line3, + "That mismatch created an extractable value movement.", } } diff --git a/internal/xfeed/runner_test.go b/internal/xfeed/runner_test.go index b1e621f..6d54ed9 100644 --- a/internal/xfeed/runner_test.go +++ b/internal/xfeed/runner_test.go @@ -39,7 +39,7 @@ func TestRunnerTagsVictimMentionInHeadline(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(res.MainPost, "๐Ÿšจ Truebit (@TruebitProtocol) exploit on Ethereum") { + if !strings.Contains(res.MainPost, "๐Ÿšจ Truebit (@TruebitProtocol) โ€” Settlement Accounting Exploit") { t.Fatalf("headline missing victim mention:\n%s", res.MainPost) } } @@ -70,28 +70,22 @@ func TestRunnerDraftsBacklightIncidentThread(t *testing.T) { t.Fatalf("result readiness/status/image = %+v", res) } for _, want := range []string{ - "[Backlight Initial Analysis]", - "๐Ÿšจ Truebit exploit on Ethereum", - "Tx: " + tx, - "๐Ÿ•’ Occurred: 2026-01-08 16:02 UTC", - "๐Ÿ’ฅ Impact: approximately ~$26.4M", + "๐Ÿšจ Truebit โ€” Settlement Accounting Exploit", + "On January 8, 2026, Truebit was exploited on Ethereum, with an estimated loss of $26.42M.", + "Key info", + "โ€ข Occurred: 2026-01-08 16:02 UTC", + "โ€ข Estimated loss: $26.42M", + "TL;DR", "Evidence points to a pricing/accounting mismatch in the Truebit market settlement path.", - "Need more detail? Check our repo and analysis thread below โ†“ ๐Ÿงต", + "Why it matters", + "Builder takeaway", } { if !strings.Contains(res.MainPost, want) { t.Fatalf("main post missing %q:\n%s", want, res.MainPost) } } - for _, want := range []string{ - "1/ Artifacts + analysis ๐Ÿงพ", - "- Report: https://github.com/BackwardLabs/Q1-2026/blob/main/test/2026-01/truebit/README.md", - "- PoC: https://github.com/BackwardLabs/Q1-2026/blob/main/test/2026-01/truebit/truebit.t.sol", - "Analysis:\nAt a high level, this is a market interaction and settlement-accounting issue.", - "Explorer:\nhttps://etherscan.io/tx/" + tx, - } { - if !strings.Contains(res.ReplyPost, want) { - t.Fatalf("reply post missing %q:\n%s", want, res.ReplyPost) - } + if want := "Tx: https://etherscan.io/tx/" + tx; res.ReplyPost != want { + t.Fatalf("reply post = %q, want %q", res.ReplyPost, want) } if strings.Contains(res.ReplyPost, "Attacker CA") { t.Fatalf("reply should omit attacker CA by default:\n%s", res.ReplyPost) @@ -99,6 +93,11 @@ func TestRunnerDraftsBacklightIncidentThread(t *testing.T) { if strings.Contains(res.MainPost, "ETH payouts") || strings.Contains(res.ReplyPost, "ETH moved") { t.Fatalf("pricing fallback should not hard-code ETH when no asset symbol is known:\nmain:\n%s\nreply:\n%s", res.MainPost, res.ReplyPost) } + for _, bad := range []string{"[Backlight", "Tx: ", "PoC", "Forge", "Report:"} { + if strings.Contains(res.MainPost, bad) { + t.Fatalf("main post contains internal/linked detail %q:\n%s", bad, res.MainPost) + } + } if !strings.Contains(res.TelegramPost, res.MainPost+"\n\nGitHub:\nhttps://github.com/BackwardLabs/Q1-2026/tree/main/test/2026-01/truebit") { t.Fatalf("telegram post did not append GitHub URL:\n%s", res.TelegramPost) } @@ -130,7 +129,7 @@ func TestRunnerUsesAssetSymbolForSettlementNarrative(t *testing.T) { } for _, want := range []string{ "USDT payouts bounded by market reserves", - "USDT leave the market reserve", + "USDT leave the affected reserve", } { if !strings.Contains(res.MainPost, want) { t.Fatalf("main post missing %q:\n%s", want, res.MainPost) @@ -269,9 +268,13 @@ func TestRunnerGeneratesExploitFlowCardImagePath(t *testing.T) { } brief := mustReadFile(t, res.CardBriefPath) for _, want := range []string{ - "# Truebit Exploit Flow", - "- **Estimated loss**: $26423938.99", - "- **Proof kind**: economic_proof", + "# Public Card Packet โ€” Truebit Incident", + "- **Disclosure status**: publishable", + "- **Headline**: Truebit โ€” Settlement Accounting Exploit", + "- **Occurred**: 2026-01-08 16:02 UTC", + "- **Impact card**: $26.42M", + "- **RCA status**: partial", + "- **Violated invariant**:", } { if !strings.Contains(brief, want) { t.Fatalf("card brief missing %q:\n%s", want, brief) @@ -305,15 +308,17 @@ func TestRunnerCardBriefUsesRCAFlowAndImpact(t *testing.T) { } brief := mustReadFile(t, res.CardBriefPath) for _, want := range []string{ - "# Pancakeswap V2 Exploit Flow", + "# Public Card Packet โ€” Pancakeswap V2 Incident", + "- **Headline**: Pancakeswap V2 โ€” LP Reserve Burn", "- **Vulnerable path**: OLPCToken.transfer -> PancakePair.skim -> PancakePair.sync", "- **Vulnerable contract**: OLPCToken (0x5881...0000)", "- **Victim**: PancakePair (impacted OLPC/LABUBU AMM pair)", - "- **Impact card**: ~$1.1M USDT", - "- **Flow step 2**: 2. skim() starts transfer", - "- **Flow step 3**: 3. pool balance drops", - "- **Flow step 4**: 4. attacker cashes out", - "- **Exploit result**: attacker cashes out", + "- **Impact card**: $1.12M", + "- **Affected assets**: USDT, LABUBU, OLPC", + "- **Flow step 2**: skim() starts transfer", + "- **Flow step 3**: pool balance drops", + "- **Flow step 4**: attacker cashes out", + "- **RCA status**: confirmed", } { if !strings.Contains(brief, want) { t.Fatalf("card brief missing %q:\n%s", want, brief) @@ -323,9 +328,9 @@ func TestRunnerCardBriefUsesRCAFlowAndImpact(t *testing.T) { "accounting or pricing mismatch", "payout > checked value", "enter vulnerable path", - "- **Violated invariant**:", - "- **Severity**:", - "- **Confidence**:", + "- **PoC status**:", + "- **Proof kind**:", + "- **Attacker gain reproduced**:", } { if strings.Contains(brief, bad) { t.Fatalf("card brief contains generic fallback %q:\n%s", bad, brief) @@ -359,13 +364,14 @@ func TestRunnerCardBriefUsesRCAVulnerableFunctionForMarginWithdrawal(t *testing. } brief := mustReadFile(t, res.CardBriefPath) for _, want := range []string{ - "- **RCA status**: partial (medium confidence)", + "- **RCA status**: partial", + "- **Confidence**: medium", "- **Vulnerable path**: changePosition(int256,int256,int256)", "- **Vulnerable contract**: vulnerable proxy (0xf7ca...80bc)", - "- **Flow step 1**: 1. reach changePosition()", - "- **Flow step 2**: 2. position/accounting changes", - "- **Flow step 3**: 3. margin check accepts withdrawal", - "- **Flow step 4**: 4. victim assets leave", + "- **Flow step 1**: reach changePosition()", + "- **Flow step 2**: position/accounting changes", + "- **Flow step 3**: margin check accepts withdrawal", + "- **Flow step 4**: victim assets leave", } { if !strings.Contains(brief, want) { t.Fatalf("card brief missing %q:\n%s", want, brief) @@ -375,7 +381,6 @@ func TestRunnerCardBriefUsesRCAVulnerableFunctionForMarginWithdrawal(t *testing. "call withdraw(victim)", "helper spends allowance", "unknown proxy", - "- **Confidence**:", } { if strings.Contains(brief, bad) { t.Fatalf("card brief contains stale/generic text %q:\n%s", bad, brief) diff --git a/internal/xpublish/publisher.go b/internal/xpublish/publisher.go index b111af1..61c5cc5 100644 --- a/internal/xpublish/publisher.go +++ b/internal/xpublish/publisher.go @@ -11,7 +11,6 @@ import ( "io" "math/big" "mime" - "mime/multipart" "net/http" "net/url" "os" @@ -117,6 +116,13 @@ type uploadMediaResponse struct { } `json:"data"` } +type authenticatedUserResponse struct { + Data struct { + ID string `json:"id"` + Username string `json:"username"` + } `json:"data"` +} + type postOptions struct { ReplyToID string MediaIDs []string @@ -200,6 +206,9 @@ func (p *Publisher) publishThread(ctx context.Context, thread Thread, template s if err != nil { return nil, err } + if err := p.verifyAuthenticatedUser(ctx, client, token.AccessToken); err != nil { + return nil, err + } var mediaIDs []string if mediaPath := strings.TrimSpace(thread.MediaPath); mediaPath != "" { media, err := p.uploadMedia(ctx, client, token.AccessToken, mediaPath) @@ -347,35 +356,30 @@ func writeSecretFile(path string, data []byte) error { } func (p *Publisher) uploadMedia(ctx context.Context, client *http.Client, accessToken, mediaPath string) (*uploadMediaResponse, error) { - source, fileName, mediaType, err := p.openMediaSource(ctx, client, mediaPath) + source, _, mediaType, err := p.openMediaSource(ctx, client, mediaPath) if err != nil { return nil, err } defer source.Close() - var body bytes.Buffer - writer := multipart.NewWriter(&body) - part, err := writer.CreateFormFile("media", fileName) + media, err := io.ReadAll(source) if err != nil { return nil, err } - if _, err := io.Copy(part, source); err != nil { - return nil, err - } - if err := writer.WriteField("media_category", defaultMediaCategory); err != nil { - return nil, err - } - if err := writer.WriteField("media_type", mediaType); err != nil { - return nil, err - } - if err := writer.Close(); err != nil { + body, err := json.Marshal(map[string]any{ + "media": base64.StdEncoding.EncodeToString(media), + "media_category": defaultMediaCategory, + "media_type": mediaType, + "shared": false, + }) + if err != nil { return nil, err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.Config.APIBase+"/2/media/upload", &body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.Config.APIBase+"/2/media/upload", bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "backlight-x-publisher/1") var out uploadMediaResponse if err := doJSON(client, req, &out); err != nil { @@ -387,6 +391,31 @@ func (p *Publisher) uploadMedia(ctx context.Context, client *http.Client, access return &out, nil } +func (p *Publisher) verifyAuthenticatedUser(ctx context.Context, client *http.Client, accessToken string) error { + expected := strings.Trim(strings.TrimSpace(p.Config.Username), "@") + if expected == "" { + return nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.Config.APIBase+"/2/users/me", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("User-Agent", "backlight-x-publisher/1") + var out authenticatedUserResponse + if err := doJSON(client, req, &out); err != nil { + return fmt.Errorf("verify x publishing account: %w", err) + } + actual := strings.Trim(strings.TrimSpace(out.Data.Username), "@") + if actual == "" { + return errors.New("verify x publishing account: response did not include data.username") + } + if !strings.EqualFold(actual, expected) { + return fmt.Errorf("verify x publishing account: authenticated as @%s, expected @%s", actual, expected) + } + return nil +} + func (p *Publisher) openMediaSource(ctx context.Context, client *http.Client, mediaPath string) (io.ReadCloser, string, string, error) { parsed, err := url.Parse(mediaPath) if err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") { diff --git a/internal/xpublish/publisher_test.go b/internal/xpublish/publisher_test.go index 0b220e8..f21290d 100644 --- a/internal/xpublish/publisher_test.go +++ b/internal/xpublish/publisher_test.go @@ -2,6 +2,7 @@ package xpublish import ( "context" + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" @@ -267,7 +268,7 @@ func TestPublishRefreshesTokenAndCreatesPost(t *testing.T) { if err := os.WriteFile(tokenFile, []byte(`{"refresh_token":"refresh-token"}`), 0o600); err != nil { t.Fatal(err) } - var sawRefresh, sawPost bool + var sawRefresh, sawAccount, sawPost bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/2/oauth2/token": @@ -302,6 +303,11 @@ func TestPublishRefreshesTokenAndCreatesPost(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]any{ "data": map[string]string{"id": "12345", "text": body["text"]}, }) + case "/2/users/me": + sawAccount = true + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]string{"id": "account-id", "username": "BackwardLabs"}, + }) default: http.NotFound(w, r) } @@ -323,8 +329,8 @@ func TestPublishRefreshesTokenAndCreatesPost(t *testing.T) { if err != nil { t.Fatal(err) } - if !sawRefresh || !sawPost { - t.Fatalf("saw refresh=%v post=%v", sawRefresh, sawPost) + if !sawRefresh || !sawAccount || !sawPost { + t.Fatalf("saw refresh=%v account=%v post=%v", sawRefresh, sawAccount, sawPost) } if !res.Published || res.PostID != "12345" || res.PostURL != "https://x.com/BackwardLabs/status/12345" || !res.RefreshReturned || !res.RefreshTokenUpdated || !res.PostTextVerified { t.Fatalf("publish result = %+v", res) @@ -348,7 +354,7 @@ func TestPublishThreadUploadsMediaAndCreatesReply(t *testing.T) { if err := os.WriteFile(mediaPath, []byte("png-data"), 0o644); err != nil { t.Fatal(err) } - var sawRefresh, sawMedia bool + var sawRefresh, sawAccount, sawMedia bool var postCount int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -364,14 +370,23 @@ func TestPublishThreadUploadsMediaAndCreatesReply(t *testing.T) { if got := r.Header.Get("Authorization"); got != "Bearer access-token" { t.Fatalf("media auth header = %q", got) } - if err := r.ParseMultipartForm(1 << 20); err != nil { + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("media content type = %q", got) + } + var body struct { + Media string `json:"media"` + MediaCategory string `json:"media_category"` + MediaType string `json:"media_type"` + Shared bool `json:"shared"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatal(err) } - if r.FormValue("media_category") != defaultMediaCategory || r.FormValue("media_type") != "image/png" { - t.Fatalf("media form category/type = %q/%q", r.FormValue("media_category"), r.FormValue("media_type")) + if body.MediaCategory != defaultMediaCategory || body.MediaType != "image/png" || body.Shared { + t.Fatalf("media body = %#v", body) } - if _, _, err := r.FormFile("media"); err != nil { - t.Fatalf("media form file missing: %v", err) + if body.Media != base64.StdEncoding.EncodeToString([]byte("png-data")) { + t.Fatalf("media payload = %q", body.Media) } _ = json.NewEncoder(w).Encode(map[string]any{ "data": map[string]string{"id": "media-1"}, @@ -412,6 +427,11 @@ func TestPublishThreadUploadsMediaAndCreatesReply(t *testing.T) { default: t.Fatalf("unexpected tweet create #%d", postCount) } + case "/2/users/me": + sawAccount = true + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]string{"id": "account-id", "username": "BackwardLabs"}, + }) default: http.NotFound(w, r) } @@ -432,8 +452,8 @@ func TestPublishThreadUploadsMediaAndCreatesReply(t *testing.T) { if err != nil { t.Fatal(err) } - if !sawRefresh || !sawMedia || postCount != 2 { - t.Fatalf("saw refresh=%v media=%v post_count=%d", sawRefresh, sawMedia, postCount) + if !sawRefresh || !sawAccount || !sawMedia || postCount != 2 { + t.Fatalf("saw refresh=%v account=%v media=%v post_count=%d", sawRefresh, sawAccount, sawMedia, postCount) } if !res.Published || res.PostID != "main-id" || res.ReplyPostID != "reply-id" || res.MediaID != "media-1" { t.Fatalf("thread publish result = %+v", res) @@ -446,6 +466,48 @@ func TestPublishThreadUploadsMediaAndCreatesReply(t *testing.T) { } } +func TestPublishThreadRejectsUnexpectedPublishingAccount(t *testing.T) { + var sawPost bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/2/oauth2/token": + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "access-token", + "expires_in": 7200, + "token_type": "bearer", + }) + case "/2/users/me": + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]string{"id": "wrong-account-id", "username": "NotBackwardLabs"}, + }) + case "/2/tweets": + sawPost = true + http.Error(w, "must not publish", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + pub := New(Config{ + Enabled: true, + ClientID: "client-id", + ClientSecret: "client-secret", + RefreshToken: "refresh-token", + APIBase: server.URL, + Username: "@BackwardLabs", + DryRun: false, + }) + pub.Client = server.Client() + _, err := pub.PublishThread(context.Background(), Thread{MainText: "must not publish"}) + if err == nil || !strings.Contains(err.Error(), "authenticated as @NotBackwardLabs, expected @BackwardLabs") { + t.Fatalf("unexpected account error = %v", err) + } + if sawPost { + t.Fatal("post was created before publishing account verification") + } +} + func TestPublishKeepsMismatchedPost(t *testing.T) { root := writeArtifacts(t) var sawDelete bool diff --git a/skills/x-feed/README.md b/skills/x-feed/README.md index 553db55..165b348 100644 --- a/skills/x-feed/README.md +++ b/skills/x-feed/README.md @@ -18,6 +18,10 @@ This repository is for editorial workflows around public-facing posts derived fr ## Operating Rule -Do not draft public posts or visual cards directly from private reports. First produce a sanitized brief, then write the feed draft or render the social card from that public-safe material. +Do not draft public posts or visual cards directly from private reports. Produce a public-safe brief and a separate internal redaction ledger; never pass the ledger downstream. Create an explicit public card packet before rendering a visual. + +Use this order: source inventory -> disclosure gate -> public-safe brief/card packet -> draft and visual -> package validation -> human approval -> Backlight publish handoff. + +This skill bundle does not own X credentials or X API side effects. The Backlight runtime owns OAuth refresh, media upload, main/reply creation, post verification, event recording, and deployment feature flags. Render a visual only when it carries real reasoning value: a causal hinge, state-consumer boundary, false-lead split, broken invariant, numeric gap, money-realization path, or selected asset movement. Do not render a visual whose only message is "a hack happened," the protocol name, or an unsupported loss number. diff --git a/skills/x-feed/skills/draft-x-exploit-thread/SKILL.md b/skills/x-feed/skills/draft-x-exploit-thread/SKILL.md index 8031fbe..18dc283 100644 --- a/skills/x-feed/skills/draft-x-exploit-thread/SKILL.md +++ b/skills/x-feed/skills/draft-x-exploit-thread/SKILL.md @@ -1,51 +1,70 @@ --- name: draft-x-exploit-thread -description: Draft curated X and Typefully threads from sanitized exploit-intelligence briefs. Use when turning public-safe private report summaries into feed posts, thread hooks, high-level exploit diagrams, or editorial variants for security audiences without exposing private report details or reproducible exploit steps. +description: Draft curated compact or rich long-form X and Typefully posts from sanitized exploit-intelligence briefs. Use when turning public-safe private report summaries into incident posts, thread hooks, high-level exploit diagrams, or editorial variants for security audiences without exposing private report details or reproducible exploit steps, including when an earlier draft feels too short or thin. --- # Draft X Exploit Thread ## Overview -Turn a sanitized exploit brief into a concise public thread with a clear security thesis, useful context, and publishable caveats. Never draft directly from raw private reports unless the first output is a sanitized brief. +Turn a sanitized exploit brief into a compact alert, substantive long-form post, or concise public thread with a clear security thesis, useful context, and publishable caveats. Never draft directly from raw private reports unless the first output is a sanitized brief. ## Required Inputs - A sanitized brief from `sanitize-exploit-report`, or equivalent public-safe notes. - Incident date/time, with basis: public tx timestamp, block timestamp, monitoring alert, report timestamp, or under review. +- Protocol X handle and verification source, or `under review` when no official handle is confirmed. - Intended audience: security researchers, builders, traders, protocol teams, or general crypto audience. -- Posting mode: main post plus reply thread, short exploratory thread, Typefully draft, or diagram-led thread. +- Posting mode: compact main post, rich long-form single post, short exploratory thread, Typefully draft, or diagram-led thread. If only private artifacts are provided, first invoke or perform the `sanitize-exploit-report` workflow and stop before writing a public draft if the disclosure boundary is unclear. ## Workflow -1. Choose the angle. +1. Validate the disclosure and claim boundary. + - Accept only a public-safe brief; keep the internal redaction ledger out of context. + - Confirm which claims, entities, timestamps, addresses, and amounts are approved for drafting. + - Verify any protocol X handle from an operator-approved value or an official project website/documentation link. Never infer it from a similar display name, hashtag, aggregator, or security alert account. + - Stop when disclosure status or coordinated-disclosure readiness is unclear. + +2. Select the posting mode and evidence status. + - Default incident coverage to one compact main post plus, only when useful, one evidence reply. + - Use a rich long-form single post when the operator says the compact draft is too short, thin, or insufficiently explanatory. + - Choose a 4-7 post exploratory thread only when the user explicitly wants deeper analysis. + - Derive evidence status internally from the approved evidence; do not print RCA, PoC, Forge, or verification badges in the public copy. + +3. Choose the angle. - Incident lens: what happened and why it matters now. - Exploit primitive lens: what pattern this reveals. - Builder lens: what design assumption failed. - Market lens: how value moved and why observers should care. -2. Draft a hook that is accurate before it is clever. +4. Draft a hook that is accurate before it is clever. - Lead with the exploit perspective, not a vague alert. + - Tag the verified protocol handle once at the first protocol mention while retaining the public display name. Use the display name without a tag when the handle is absent or under review. - Avoid sensational claims, blame, or identity attribution. - Avoid "full breakdown" language if details are intentionally abstracted. -3. Build the thread. +5. Build the thread. - Read `references/incident-post-format.md` when the user asks for the Backlight incident format, incident alert, main post, or reply thread. - - Use 4-7 posts for exploratory threads; use the Backlight incident card format for alerts and verified incident posts. + - Put protocol, chain, incident time, estimated loss, and a one- or two-sentence mechanism summary in the main post. + - For rich long-form mode, include `Key info`, `TL;DR`, causal explanation, `Why it matters`, a builder takeaway, and any material loss caveat in the single main post. + - In rich long-form mode, keep the only reply to the approved transaction explorer link unless the operator asks for a thread. + - Outside rich long-form mode, put the explorer link and optional beginner-friendly context in one reply; omit the reply when it adds no useful public evidence. + - Use 4-7 posts only for explicitly requested exploratory threads. - Each post should carry one idea. - Keep numbers and entities generalized unless explicitly publishable. - Include one visual candidate when it clarifies attacker behavior, asset movement, or failed assumptions. - - Use `exploit-flow-card` when the user asks for an X image/card, attack-flow visual, money-movement diagram, or social preview asset. + - Use `exploit-flow-card` only after creating its explicit public card packet when the user asks for an X image/card, attack-flow visual, money-movement diagram, or social preview asset. -4. Run the safety pass. +6. Validate the complete package. - Read `references/thread-style.md`. - Read `references/visual-artifacts.md` before recommending images. - Before recommending or rendering a visual, identify the one visualizable value it protects: causal hinge, state-consumer boundary, false-lead split, broken invariant, numeric gap, money-realization path, or selected asset movement. - - Use `exploit-flow-card/scripts/render_card.py` for the visual. When `asset_deltas.dot` or `run_summary` asset legs exist, fold selected movement into the same flow card instead of producing a second graph. + - Use `exploit-flow-card/scripts/render_card.py` for the visual. Do not pass raw `asset_deltas.dot` or `run_summary` downstream; put only approved selected movement in the public card packet. - Check that every claim traces back to the sanitized brief. - Remove reproduction detail and raw private provenance. + - Stop at the approved draft package. Hand the final main text, transaction-only reply, and card path to Backlight's existing x-feed/X publisher; do not add an X API client to this skill bundle. ## Output Format @@ -57,13 +76,10 @@ If only private artifacts are provided, first invoke or perform the `sanitize-ex ### X Thread Main Post: -[Core incident post with protocol/chain headline, tx hash, date/time, impact, root cause, beginner-friendly three-line explanation, CTA to replies, and one primary visual when safe.] +[Compact incident post, or a rich long-form post with Key info, TL;DR, causal explanation, why it matters, builder takeaway, caveat, and one primary visual when safe.] Reply Thread: -1. [Artifacts with report/PoC, beginner-friendly public analysis, and explorer URL. Attacker CA is opt-in only.] - -### Telegram Publish -[Main X body exactly, followed by GitHub and X links.] +1. [For compact mode: optional public-safe context and approved links. For rich long-form mode: transaction explorer URL only unless the operator requests a thread.] ### Typefully Notes - [Line breaks, optional variants, or timing notes] diff --git a/skills/x-feed/skills/draft-x-exploit-thread/references/incident-post-format.md b/skills/x-feed/skills/draft-x-exploit-thread/references/incident-post-format.md index 38c318f..659e41f 100644 --- a/skills/x-feed/skills/draft-x-exploit-thread/references/incident-post-format.md +++ b/skills/x-feed/skills/draft-x-exploit-thread/references/incident-post-format.md @@ -1,13 +1,15 @@ # Backlight Incident Post Format -Use this format for X/Typefully incident posts. Split output into a compact main post and a single reply: the main post carries the core incident information and one primary visual, while report/PoC links, public analysis, and explorer links go into the reply. +Use this format for X/Typefully incident posts. Default to one compact main post and attach one public-safe visual. Add a single reply only when an explorer link, public report, or short explanation materially helps the reader. -## Status Labels +## Internal Evidence Status - `Verified Incident`: use only when the exploit, impact, and core mechanism are supported by on-chain evidence and reproducible testing. - `Initial Analysis`: use when the main storyline is supported but root cause, impact, affected deployments, or remediation status is still being refined. - `Incident Alert`: use when the incident is active, impact is under review, or details are intentionally sparse. +Use these labels to control claim strength. Do not print the label or add RCA, PoC, Forge, or verification badges to the public post. + ## Time Rules - Use `Occurred` for the public on-chain incident time, preferably from the first confirmed exploit transaction or block timestamp. @@ -15,28 +17,33 @@ Use this format for X/Typefully incident posts. Split output into a compact main - Use `under review` if the exact incident time is unclear. - Do not publish internal detection, triage, monitoring, or analyst timestamps unless explicitly approved. +## Protocol Tagging Rules + +- Accept `Protocol X handle` only with an operator-approved value or a verification source on the protocol's official website or official documentation. +- Preserve the protocol display name and tag once at the first mention: `CrowdRingCircle (@verified_handle)` or `๐Ÿšจ CrowdRingCircle (@verified_handle) โ€” LP Reserve Burn`. +- Do not treat an incident-alert account, hashtag, news account, token aggregator, similarly named project, or unverified search result as the protocol account. +- If the handle is absent, ambiguous, suspended, renamed, or under review, omit the tag and keep the plain display name. +- Record handle verification in internal notes; do not expose the verification URL in the post unless the user asks. + ## Main Post Template -Keep the main post short, readable, and link-light. Attach one public-safe visual to the main post when available. Put report/PoC links in the reply, then use a CTA line to open the thread. +Keep the main post under the X character limit and easy to scan. Lead with what happened, then show the incident time and loss. End with one or two plain-English sentences about the mechanism. Do not repeat validation metadata that belongs to the internal workflow. -```text -[Backlight ] +Use progressive disclosure: -๐Ÿšจ exploit on -Tx: <0x1234...abcd> +1. Main post: protocol plus primitive/effect headline, chain, time, loss, and one causal summary. +2. Optional reply: a three- or four-line causal chain, affected assets, and approved links. +3. Linked public report, when available: `Key info -> TL;DR -> Root cause -> Attack flow -> Recommendation -> References`. -๐Ÿ•’ Occurred: -๐Ÿ’ฅ Impact: +Do not collapse the linked report into the X post. The post should remain useful without the link, while the report carries the evidence and technical depth. -๐Ÿ”Ž Root cause: - +```text +๐Ÿšจ <(@verified_protocol_handle) when approved> โ€” -What happened: - - - + ยท +๐Ÿ’ฅ Estimated loss: <$X | $X+ for a lower bound | under review> -Need more detail? Check our repo and analysis thread below โ†“ ๐Ÿงต + Image: - @@ -48,29 +55,81 @@ Attach one image to the main post when a safe visual is available. Treat the `Im - Avoid: raw trace screenshots, calldata, exact tx ordering, private report screenshots, or reproduction-ready diagrams. - If no safe visual exists, publish text-only and include the visual recommendation in internal notes. -## Reply Thread Template +Do not put the transaction hash, status label, report link, PoC status, root-cause heading, or generic thread CTA in the main post. The attached card already carries the structured incident facts; the copy should add a readable summary rather than duplicate every card field. + +## Rich Long-Form Main Post + +Use this mode when the operator wants one substantive X post instead of a compact alert or analysis replies. Confirm that the publishing account supports the intended long-form post before publication. -Use one reply only. Keep timing and pattern labels out of the reply because the main post already carries the key identifiers. Include the explorer URL when a full transaction hash is approved for publication. +Build the post in this order: + +1. Headline plus one sentence naming the incident, chain, date, and lower-bound loss. +2. `Key info`: chain, occurred time, estimated loss, affected pool or component, and affected assets. +3. `TL;DR`: when approved evidence supports the comparison, distinguish the actual defect from plausible but rejected causes, then explain the causal mechanism in two or three short paragraphs. +4. `Why it matters`: state the protocol or AMM assumption that failed and why the pattern generalizes. +5. `Builder takeaway`: give one safe design rule or remediation principle without line-level patch instructions. +6. End with a material uncertainty or valuation caveat when applicable. + +Use approximately 900-1,600 characters as an editorial target, not as a hard requirement. Keep the main post self-contained. Attach the public-safe card, but allow useful factual overlap because the text and image may be consumed separately. + +Do not include contract addresses, selectors, calldata, raw traces, PoC status, Forge status, reproduction ratios, exact operational loops, or private artifact provenance. Put only the approved transaction explorer URL in one reply. ```text -1/ Artifacts + analysis ๐Ÿงพ +๐Ÿšจ <(@verified_protocol_handle) when approved> โ€” + + + +Key info +โ€ข Chain: +โ€ข Occurred: +โ€ข Estimated loss: <$X | $X+ | under review> +โ€ข Affected component: +โ€ข Affected assets: + +TL;DR + -Artifacts: -- Report: -- PoC: + -Analysis: - + -Explorer: - +Why it matters + + +Builder takeaway + + + +``` + +## Optional Reply Template + +Use one reply at most. Include the explorer URL when the transaction is approved for publication. Include a report URL only after it is public. Keep reproduction and validation details internal. + +```text +Root cause + + +โ†’ +โ†’ +โ†’ + +Affected assets: +Report: +Tx: ``` Do not include attacker contract/address details by default. Add an `Attacker CA` block only when the operator explicitly approves attacker CA publication. -## Telegram Publish Template +For rich long-form mode, replace the optional reply body with a transaction-only reply: + +```text +Tx: +``` + +## Post-Publish Telegram Handoff -Telegram is a publish channel, not a review channel. After live X posting succeeds, send the main X body exactly, then append the public GitHub directory/report URL and the main X post URL: +Do not include this in the draft package. After explicit publication approval and a successful live X post, send the main X body exactly, then append the public GitHub directory/report URL and the main X post URL: ```text
@@ -84,20 +143,16 @@ X: ## Storyline Rules -- In the main post `What happened`, use three short narrative lines instead of bullets. -- Do not start the `What happened` lines with `-`; write them as plain explanatory sentences. +- Keep the main mechanism summary to one or two short sentences instead of a labeled `What happened` section. - Write for web3 beginners: avoid function signatures, internal stage names, raw traces, and reproduction-oriented ordering. -- Start with the attacker's observable behavior when available: deployment, setup, routing, or bundled execution. -- Use the second line for the failed protocol assumption: accounting, pricing, validation, permission, settlement, oracle trust, or lifecycle assumption. -- Use the third line for the consequence: value moved, permission changed, accounting diverged, or redemption/settlement became profitable. -- Keep the whole `What happened` block to about three readable lines. +- Prefer the failed assumption and consequence over a step-by-step attacker narrative. - Prefer "under review" over weak guesses when root cause, timing, or impact is not firm. -- Put report URLs, PoC status, public analysis, and explorer links in the single reply, not the main post. -- End the main post with the CTA when there is at least one reply: `Need more detail? Check our repo and analysis thread below โ†“ ๐Ÿงต` +- Put public report and explorer links in the optional reply, not the main post. +- Keep PoC, Forge, reproduction ratios, and internal validation status out of public copy. - Treat attacker CA as opt-in, not a required field. ## Emoji Rules -- Use emoji as section markers only: ๐Ÿšจ incident, ๐Ÿ•’ time, ๐Ÿ’ฅ impact, ๐Ÿ”Ž root cause, ๐Ÿงพ reply artifacts. +- Use emoji as section markers only: ๐Ÿšจ incident, ๐Ÿ•’ time, ๐Ÿ’ฅ impact. - Do not stack decorative emoji or use emoji inside every sentence. - Keep labels stable so readers can scan repeated Backlight posts quickly. diff --git a/skills/x-feed/skills/exploit-flow-card/SKILL.md b/skills/x-feed/skills/exploit-flow-card/SKILL.md index 0fac453..5860f58 100644 --- a/skills/x-feed/skills/exploit-flow-card/SKILL.md +++ b/skills/x-feed/skills/exploit-flow-card/SKILL.md @@ -1,24 +1,31 @@ --- name: exploit-flow-card -description: Create public-safe X-ready exploit-flow cards from Backlight-style public REPORT.md files, sanitized exploit briefs, LumosKit report_bundle outputs, RCA summaries, or feed drafts. Use when preparing one visual asset for X/Typefully posts that explains the exploit path, affected component, failure point, broken invariant, selected asset movement, RCA confidence, and impact without exposing private traces, pipeline internals, PoC code, or reproducible exploit detail. +description: Create public-safe X-ready exploit-flow cards from a normalized public card packet or a public-facing Backlight REPORT.md. Use when preparing one visual asset for X or Typefully that explains an approved exploit path, failure point, incident time, observed loss, affected assets, and invariant. Normalize private reports, raw report bundles, RCA artifacts, and feed drafts through the disclosure workflow before invoking this skill. --- # Exploit Flow Card ## Overview -Create one public-safe social flow card that explains an exploit in a way a non-specialist can follow. Prefer deterministic SVG/PNG output over AI-generated images because protocol names, RCA confidence, proof status, and loss numbers must stay exact. +Create one public-safe social flow card that explains an exploit in a way a non-specialist can follow. Prefer deterministic SVG/PNG output over AI-generated images because protocol names, incident time, loss figures, and causal labels must stay exact. -Use this skill after `sanitize-exploit-report` when source material is private. If the input is already a public-facing `REPORT.md` from a report bundle, it can be used directly. +Use this skill after `sanitize-exploit-report` when source material is private. Keep private redaction notes outside the renderer input. A public-facing `REPORT.md` can be used directly; bundle JSON remains opt-in because it can contain internal provenance and raw economics. ## Workflow -1. Confirm the disclosure boundary. - - Use only sanitized briefs, public-safe notes, feed drafts, or public-facing `report_bundle/report/REPORT.md`. - - Do not render raw trace steps, calldata, exploit code, private artifact paths, pipeline timing, partial/blocked status, or model/agent process details. - - Keep function names only when they are already part of the public report and necessary to explain the high-level flow. +1. Classify the source and apply the disclosure gate. + - For private input, run `sanitize-exploit-report`, then create a separate public card packet using `references/public-card-packet.md`. + - Stop when disclosure status is absent, blocked, embargoed, or under review. + - Keep the redaction ledger, raw traces, bundle JSON, internal paths, and reproduction detail out of the public packet. + - For a public-facing `REPORT.md`, render from the Markdown alone by default. -2. Choose the visual value and shape from the report fields. +2. Normalize and validate the renderer input. + - Read `references/public-card-packet.md` when the source is not already a public-facing `REPORT.md`. + - Require one approved finding, one violated invariant, explicit RCA confidence/status, and three causal flow steps. + - Show observed incident loss with lower-bound or unpriced-asset caveats. Keep reproduction-only economics out of the public image. + - Use `confirmed` only when the approved finding and invariant are present and evidence supports that status. + +3. Choose the visual value and shape from approved fields. - Render only when there is a specific visualizable value: causal hinge, state-consumer boundary, false-lead split, broken invariant, numeric gap, money-realization path, or selected asset movement. - Do not render an image whose only value is "hack happened," protocol name, a generic timeline, or an unsupported loss number. - Use `Root Cause` as the primary source: `Finding`, `In short`, `Severity`, `Confidence`, and `Violated invariant`. @@ -28,13 +35,18 @@ Use this skill after `sanitize-exploit-report` when source material is private. - If `Finding`, `Violated invariant`, or `Confidence` says the RCA is unproven, low-confidence, or under review, the card must say that directly instead of inventing a vulnerability. - Do not use ellipses for card copy. If text is too long, rewrite it into a shorter complete phrase. -3. Generate the asset. - - Run `scripts/render_card.py` with a public-safe `REPORT.md`, a `report_bundle` directory, or a sanitized brief markdown file. - - When a LumosKit `report_bundle` includes `run_summary` asset legs or `visuals/asset_deltas.dot`, use that material only to populate the same flow card's asset movement node. +4. Generate the asset. + - Run `scripts/render_card.py` with a public card packet or public-facing `REPORT.md`. + - Passing a `report_bundle` directory reads only its public `report/REPORT.md` by default. + - Use `--include-bundle-json` only after separately confirming that the JSON fields are approved for public use. - Write outputs outside private artifact trees unless the user explicitly asks otherwise. - Produce SVG always; produce PNG when `cairosvg` is installed. -4. Review the image before presenting it. +5. Validate the rendered package. + - Confirm that incident time, observed loss, affected assets, and causal claims agree with the public packet. + - Confirm that every visible causal phrase maps to an approved packet field. + +6. Review the image before presenting it. - Check that all text fits and no labels overlap. - Check that the card does not include private paths, internal state labels, raw traces, or reproduction instructions. - If a PNG renderer is unavailable, present the SVG path and note the missing dependency. @@ -42,8 +54,8 @@ Use this skill after `sanitize-exploit-report` when source material is private. ## Script ```bash -python skills/exploit-flow-card/scripts/render_card.py \ - /path/to/report_bundle \ +python3 skills/exploit-flow-card/scripts/render_card.py \ + /path/to/public-card-packet.md \ --out-dir /tmp \ --basename exploit-flow-card ``` @@ -52,34 +64,34 @@ Useful options: - `--no-png`: write only SVG. - `--title`: override the card title. +- `--include-bundle-json`: opt in to structured bundle fields after disclosure review. ## Visual Rules - Size: 1200x675. -- Title: protocol or incident name plus "Exploit Flow" unless the user explicitly asks for an RCA-only card. +- Title: prefer a short `protocol โ€” primitive/effect` headline from the public packet, such as `CrowdRingCircle โ€” LP Reserve Burn`. Fall back to the protocol or incident name plus `Exploit Flow` only when no approved causal headline exists. - Subtitle: one short complete phrase that says what happened in plain language. -- Header: include compact RCA status and chain chips, but keep them secondary to the flow. +- Header: show chain, public incident time, and observed loss. Keep RCA confidence, PoC status, Forge status, and internal evidence labels out of the image. - Main visual: use graph-like flow-card rendering: role nodes, thin directional arrows, selected asset movement, and a compact broken-invariant card. - Flow order: keep numbered steps on the main path only: actor action -> vulnerable surface/failure -> asset movement. Connect the broken invariant as an unnumbered annotation, not a fourth flow step. - Text hierarchy: separate node titles from body copy with a small field label such as Action, Surface gap, Failure step, Effect, or Observed loss. Keep title, field label, and body baselines consistently spaced. - Asset movement: do not publish raw DOT. Use role labels over full addresses and show the selected value movement once. - Flow nodes: use beginner-readable effect labels. Prefer "pool balance drops", "check uses stale state", and "attacker receives funds" over internal shorthand. - Arrows: make direction obvious. Use arrow color to distinguish observed calls, uncertain/failed checks, root-cause-relevant impact, and verified outcomes. -- RCA/evidence: keep RCA status in the header and put the violated invariant in one compact lower-left card. +- RCA/evidence: use RCA status only as an internal render gate; put the approved violated invariant in one compact lower-left card without a public status badge. - Typography: use dark text on the light card background; reserve blue, amber, red, and green only for semantic emphasis. Avoid low-contrast gray body copy because it becomes weak in X image previews. +- Branding: include a subtle repeated `BACKWARD LABS` watermark and a small readable bottom-right `BACKWARD LABS` mark. Keep both subordinate to incident content. - Font scale: favor large, scan-friendly text over dense annotations; trim wording before shrinking type. - Do not render raw execution minutiae. Abstract the flow until a general reader can understand who acted, what component was hit, what failed, and what value moved. - Line colors must carry stable meaning: - Blue: observed transaction trigger or externally visible call. - - Amber: RCA status, evidence gap, uncertain RCA link, or missing provenance. + - Amber: invariant link, evidence gap, uncertain causal link, or missing provenance. - Red: violated invariant, failed assumption, or root-cause-relevant impact. - - Green: verified public artifact or confirmed proof status. - Keep tx hashes, proof status, and report links out of the image; put them in the post text or thread instead. ## Safety Rules - Do not include `Observed Trace Flow`, pipeline timing, artifact paths, internal RCA state, private labels, model names, or agent tool output. - Do not include exact calldata, exploit code, storage slots, private frame IDs, or line-level exploit recipes. -- Do not call a PoC "verified" unless the provided public source says the PoC and proof passed. - Do not invent loss amounts, asset symbols, root cause, affected contracts, or attacker routing. - If the source is ambiguous, label the center as "root cause not proven" or "root cause remains under review" instead of inventing a vulnerability. diff --git a/skills/x-feed/skills/exploit-flow-card/references/public-card-packet.md b/skills/x-feed/skills/exploit-flow-card/references/public-card-packet.md new file mode 100644 index 0000000..7c3fc53 --- /dev/null +++ b/skills/x-feed/skills/exploit-flow-card/references/public-card-packet.md @@ -0,0 +1,39 @@ +# Public Card Packet + +Use this packet as the only renderer input derived from private incident artifacts. Keep the redaction ledger and private evidence outside this file. + +## Required fields + +```markdown +# Public Card Packet โ€” + +- **Disclosure status**: approved-for-draft | public | publishable +- **Protocol**: +- **Headline**: +- **Chain**: +- **Occurred**: +- **Finding**: +- **In short**: +- **Severity**: critical | high | medium | low | under review +- **Confidence**: high | medium | low | under review +- **RCA status**: confirmed | partial | low confidence | unproven +- **Vulnerable contract**: +- **Vulnerable path**: +- **Victim**: +- **Impact card**: +- **Impact note**: +- **Affected assets**: +- **Flow step 1**: +- **Flow step 2**: +- **Flow step 3**: +- **Flow step 4**: +- **Violated invariant**: +``` + +## Gate rules + +- Stop when `Disclosure status` is absent, blocked, embargoed, or under review. +- Keep exact addresses, calldata, private paths, raw deltas, internal timestamps, and redaction notes out of the packet. +- Use `confirmed` only when the finding and violated invariant are present and confidence is high or an explicit public status says confirmed. +- Treat unrelated evidence limitations as notes; do not downgrade a confirmed RCA unless the limitation challenges the root-cause claim itself. +- Keep RCA confidence, PoC status, Forge status, and reproduction-only economics in internal validation notes; do not render them on the public card. diff --git a/skills/x-feed/skills/exploit-flow-card/scripts/render_card.py b/skills/x-feed/skills/exploit-flow-card/scripts/render_card.py index dfa104e..d704c4c 100755 --- a/skills/x-feed/skills/exploit-flow-card/scripts/render_card.py +++ b/skills/x-feed/skills/exploit-flow-card/scripts/render_card.py @@ -10,6 +10,7 @@ import shutil import subprocess import sys +from datetime import datetime, timezone from decimal import Decimal, InvalidOperation from pathlib import Path @@ -26,17 +27,31 @@ def main() -> int: parser.add_argument("--title", default=None) parser.add_argument("--footer", default=None, help=argparse.SUPPRESS) parser.add_argument("--no-png", action="store_true", help="only write SVG") + parser.add_argument( + "--include-bundle-json", + action="store_true", + help="opt in to report.json and run_summary.json after disclosure review", + ) args = parser.parse_args() report_path, bundle_path, case_readme_path = resolve_source(args.input) text = report_path.read_text(encoding="utf-8") - report_json = load_json(bundle_path / "report" / "report.json") if bundle_path else {} - run_summary = load_json(bundle_path / "report" / "run_summary.json") if bundle_path else {} + report_json = ( + load_json(bundle_path / "report" / "report.json") + if bundle_path and args.include_bundle_json + else {} + ) + run_summary = ( + load_json(bundle_path / "report" / "run_summary.json") + if bundle_path and args.include_bundle_json + else {} + ) case_readme = ( case_readme_path.read_text(encoding="utf-8") if case_readme_path and case_readme_path.is_file() else "" ) + validate_public_packet(text) data = extract_card_data(text, report_json, run_summary, case_readme, bundle_path) if args.title: data["title"] = args.title @@ -132,6 +147,34 @@ def load_json(path: Path) -> dict: return {} +def validate_public_packet(text: str) -> None: + disclosure_status = field_value(text, "Disclosure status") + is_packet = bool(disclosure_status) or "public card packet" in text.lower() + if not is_packet: + return + allowed_statuses = {"approved-for-draft", "public", "publishable"} + normalized_status = clean_inline(disclosure_status or "").lower().replace("_", "-") + if normalized_status not in allowed_statuses: + raise SystemExit( + "public card packet is blocked: Disclosure status must be approved-for-draft, public, or publishable" + ) + required = ( + "Protocol", + "Chain", + "Occurred", + "Finding", + "Confidence", + "RCA status", + "Violated invariant", + "Flow step 1", + "Flow step 2", + "Flow step 3", + ) + missing = [label for label in required if not field_value(text, label)] + if missing: + raise SystemExit("public card packet is missing required fields: " + ", ".join(missing)) + + def extract_card_data( text: str, report_json: dict | None = None, @@ -251,12 +294,7 @@ def extract_card_data( if flow_kind == "rollup_auth": center_title = "RollupProcessorV3" if "rollupprocessorv3" in text.lower() else "RollupProcessor" center_detail = compact_center_detail(vulnerability, flow["center_detail"], flow_kind) - center_status = status_with_value( - compact_center_status(exploit_result, flow["center_status"], flow_kind), - attacker_gain_label, - market_loss, - flow_kind, - ) + center_status = compact_center_status(exploit_result, flow["center_status"], flow_kind) center_status_tone = "red" zero_economic_flow = money_is_zero(market_loss) and money_is_zero(attacker_gain_label) if loop_label: @@ -271,8 +309,8 @@ def extract_card_data( loop_text = flow.get("step5", "") else: loop_text = "" - finding = field_value(text, "Finding") or center_status - in_short = field_value(text, "In short") or one_liner or center_detail + finding = field_value(text, "Finding") or vulnerability + in_short = field_value(text, "In short") or one_liner or "" vulnerability_obj = deep_get(report_json, "vulnerability") or {} violated_invariant = ( field_value(text, "Violated invariant") @@ -301,7 +339,7 @@ def extract_card_data( target_label = surface_display_name(vulnerable_contract) or protocol or "target contract" if flow_kind == "rollup_auth": target_label = "Aztec proxy escrow" - title_text = f"{protocol} Exploit Flow" if protocol else title + title_text = field_value(text, "Headline") or (f"{protocol} Exploit Flow" if protocol else title) title_text = title_text.replace("(", "").replace(")", "") proof_text = proof_label(poc_status, forge_test, proof_kind) @@ -313,12 +351,17 @@ def extract_card_data( market_loss, ) asset_loss = asset_delta_labels(run_summary, market_loss, text) + loss_note = impact_loss_note(text) + occurred = incident_time_label(text, run_summary) + affected_assets = affected_assets_label(text, run_summary) return { "title": fit(title_text, 34), "subtitle": fit(subtitle, 64), "flow_kind": flow_kind, "chain": fit(chain_label(chain), 22), + "occurred": fit(occurred, 38), + "headline_loss": fit(headline_loss_label(market_loss), 28), "protocol": fit(protocol or "Protocol", 24), "center_title": fit(center_title, 52), "center_detail": fit(center_detail, 56), @@ -339,6 +382,8 @@ def extract_card_data( "market_loss": fit(market_loss, 28), "attacker_gain": fit(attacker_gain_label, 28), "asset_loss": fit(asset_loss, 34), + "loss_note": fit(loss_note, 38), + "affected_assets": fit(affected_assets, 34), "flow_failure": fit(center_detail or flow_plan["failure"], 44), "flow_outcome": fit(flow_plan["outcome"], 44), "root_finding": root_finding_label(finding, rca_status), @@ -452,9 +497,11 @@ def contract_label(item: dict) -> str: short = short_address(address) if unknown_contract_name(name): role_label = contract_role_label(" ".join(part for part in (role, name) if part)) - if role_label and short: - return f"{role_label} ({short})" - return short + if role_label: + return role_label + if short: + return "Affected contract" + return "" return name @@ -564,6 +611,66 @@ def loss_from_text(text: str) -> str: return "" +def impact_loss_note(text: str) -> str: + lowered = text.lower() + lower_bound = "lower bound" in lowered + unpriced = "unpriced leg" in lowered or "asset leg is unpriced" in lowered + if lower_bound and unpriced: + count = re.search(r"(\d+)\s+unpriced leg", lowered) + if count: + return f"lower bound ยท {count.group(1)} unpriced leg" + return "lower bound ยท unpriced leg excluded" + if lower_bound: + return "lower bound" + if unpriced: + return "unpriced leg excluded" + return "" + + +def incident_time_label(text: str, run_summary: dict) -> str: + raw = field_value(text, "Occurred") or field_value(text, "Incident time") or "" + if not raw: + timestamp = deep_get(run_summary, "economic_reproduction", "pricing", "tx_timestamp") + try: + raw = datetime.fromtimestamp(int(timestamp), tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M UTC" + ) + except (TypeError, ValueError, OSError): + raw = "under review" + normalized = re.sub(r"(?<=\d)T(?=\d)", " ", clean_inline(raw)) + normalized = re.sub(r":\d{2}(?:\.\d+)?(?:Z|\+00:00)$", " UTC", normalized) + normalized = normalized.replace("Z", " UTC") + return "Occurred ยท " + normalized + + +def headline_loss_label(value: str) -> str: + cleaned = clean_inline(value) + match = re.search(r"\$[0-9][0-9,.]*(?:[KMB])?", cleaned, re.I) + if match: + return "Loss ยท " + match.group(0) + if cleaned.lower() in {"", "unknown", "under review"}: + return "Loss ยท under review" + return "Loss ยท " + fit(cleaned, 18) + + +def affected_assets_label(text: str, run_summary: dict) -> str: + raw = field_value(text, "Affected assets") or field_value(text, "Main affected assets") or "" + if raw: + assets = [clean_inline(item) for item in re.split(r"[,;+]", raw) if clean_inline(item)] + return " ยท ".join(assets[:3]) + legs = deep_get(run_summary, "economic_reproduction", "incident", "asset_legs") + if not isinstance(legs, list): + return "Assets under review" + symbols: list[str] = [] + for item in legs: + if not isinstance(item, dict): + continue + symbol = clean_inline(str(item.get("symbol") or "")) + if symbol and symbol not in symbols: + symbols.append(symbol) + return " ยท ".join(symbols[:3]) if symbols else "Assets under review" + + def protocol_loss_override_from_text(text: str) -> str: lowered = text.lower() if ( @@ -936,27 +1043,46 @@ def classify_rca_status( limitations: list[str], status_text: str = "", ) -> str: + finding_text = clean_inline(finding) + invariant_text = clean_inline(violated_invariant) + confidence_text = clean_inline(confidence).lower() + explicit_status = clean_inline(status_text).lower() + limitation_text = " ".join(limitations).lower() joined = " ".join( - [finding, violated_invariant, confidence, " ".join(limitations), status_text] + [finding_text, invariant_text, confidence_text, limitation_text, explicit_status] ).lower() - has_finding = bool(clean_inline(finding) or clean_inline(violated_invariant)) + has_finding = bool(finding_text) + has_invariant = bool(invariant_text) if ( "no patchable root cause" in joined or "not proven" in joined or "unproven" in joined ): return "unproven" - if "partial" in joined or "scope_limited" in joined or "scope limited" in joined: - return "partial" - if "medium" in confidence.lower(): + if not has_finding and not has_invariant: + return "unproven" + if ( + "partial" in explicit_status + or "scope_limited" in explicit_status + or "scope limited" in explicit_status + or "blocked" in explicit_status + ): return "partial" - if "low" in confidence.lower(): + if "low" in confidence_text: return "low_confidence" - if has_finding and "gap" in joined: + if "medium" in confidence_text: return "partial" if "under review" in joined: - return "partial" if has_finding else "unproven" - return "confirmed" + return "partial" + if explicit_status in {"confirmed", "complete", "verified"} and has_finding and has_invariant: + return "confirmed" + if confidence_text == "high" and has_finding and has_invariant: + return "confirmed" + if "root cause" in limitation_text and any( + marker in limitation_text for marker in ("gap", "partial", "missing", "unresolved") + ): + return "partial" + return "partial" def rca_subtitle(finding: str, status: str) -> str: @@ -973,7 +1099,7 @@ def root_status_label(status: str) -> str: if status == "confirmed": return "ROOT CAUSE CONFIRMED" if status == "partial": - return "" + return "RCA PARTIAL" if status == "low_confidence": return "LOW CONFIDENCE" return "ROOT CAUSE UNPROVEN" @@ -1004,6 +1130,8 @@ def observed_path_label(in_short: str, mechanism: list[str], vulnerable_path: st def root_finding_label(value: str, status: str) -> str: cleaned = clean_inline(value) lowered = cleaned.lower() + if not cleaned and status == "unproven": + return "Root cause remains under review" if "non-provider" in lowered and "escape hatch" in lowered: return "Non-provider rollups accepted while escape hatch was closed" if "no patchable root cause" in lowered: @@ -1017,6 +1145,8 @@ def root_finding_label(value: str, status: str) -> str: def root_detail_label(value: str, status: str) -> str: cleaned = clean_inline(value) + if not cleaned and status == "unproven": + return "No public root-cause detail was supplied" if status == "unproven": cleaned = re.sub(r"^the current transaction calls\s+", "", cleaned, flags=re.I) cleaned = re.sub(r"^the observed transaction calls\s+", "", cleaned, flags=re.I) @@ -1114,6 +1244,8 @@ def preferred_value(attacker_gain: str, market_loss: str) -> str: def status_with_value(status: str, attacker_gain: str, market_loss: str, flow_kind: str) -> str: value = preferred_value(attacker_gain, market_loss) + if flow_kind == "pair_inventory_burn": + return status if flow_kind == "margin_withdrawal": value = market_loss if useful_money(market_loss) else value if not value: @@ -1153,6 +1285,8 @@ def explanatory_subtitle(flow_kind: str, source: str, attacker_gain: str, market return "fresh LP harvest bypasses JIT guard and collects fees early" if flow_kind == "skim_sync": return "token transfer side effect drains AMM reserves before cash-out" + if flow_kind == "pair_inventory_burn": + return "sell transfer burns pair inventory before reserves synchronize" if flow_kind == "buy_sell": return "buy/sell settlement accounting releases more value than checked" if flow_kind == "reward_claim": @@ -1179,6 +1313,8 @@ def actor_detail_label(flow_kind: str, step1: str, target_label: str) -> str: return "uses a fresh LP position, then calls harvest too early" if flow_kind == "skim_sync": return "sets up pair excess, then repeats skim/sync calls" + if flow_kind == "pair_inventory_burn": + return readable_fragment(flow_label(step1), 76) if flow_kind == "buy_sell": return "routes through buy and sell paths that disagree on value" if flow_kind == "reward_claim": @@ -1202,6 +1338,7 @@ def actor_edge_label(flow_kind: str, step1: str) -> str: "collateral_check": "collateral state setup", "jit_harvest": "fresh LP position", "skim_sync": "pair excess setup", + "pair_inventory_burn": "sell transfer", "buy_sell": "buy path entered", "reward_claim": "receipt stake to fresh helper", "margin_withdrawal": "changePosition() reached", @@ -1218,6 +1355,7 @@ def failure_step_label(flow_kind: str, step2: str, center_detail: str) -> str: "collateral_check": "solvency check is skipped", "jit_harvest": "JIT guard is skipped", "skim_sync": "token hook overburns pair balance", + "pair_inventory_burn": "pair inventory burns before seller credit", "buy_sell": "buy/sell accounting diverges", "reward_claim": "claim runs with zero reward debt", "margin_withdrawal": "withdrawable equity check is bypassed", @@ -1239,6 +1377,8 @@ def value_edge_label(flow_kind: str, step3: str, asset_loss: str) -> str: return f"{asset} fees pay early" if asset else "fees pay early" if flow_kind == "skim_sync": return f"{asset} balance drops" if asset else "pool balance drops" + if flow_kind == "pair_inventory_burn": + return "distorted swap extracts pool value" if flow_kind == "buy_sell": return f"{asset} reserve drains" if asset else "reserve drains" if flow_kind == "reward_claim": @@ -1338,7 +1478,11 @@ def clean_inline(value: str) -> str: def chain_label(chain: str) -> str: if not chain: return "Chain under review" - return chain.split("(")[0].strip().title() + cleaned = chain.split("(")[0].strip() + lowered = cleaned.lower() + if lowered in {"bsc", "bnb", "bnb chain", "bnb smart chain"}: + return "BNB Smart Chain" + return cleaned.title() def infer_path_from_text(text: str) -> str: @@ -1428,6 +1572,12 @@ def classify_flow(text: str) -> str: return "jit_harvest" if "buytru" in lowered and "selltru" in lowered: return "buy_sell" + if ( + "burn" in lowered + and "sync" in lowered + and ("pair inventory" in lowered or "pair's existing" in lowered or "pair balance" in lowered) + ): + return "pair_inventory_burn" if "skim" in lowered and "sync" in lowered: return "skim_sync" if "claimreward" in lowered or "reward" in lowered and "staking" in lowered: @@ -1516,6 +1666,8 @@ def compact_center_detail(raw: str, fallback: str, flow_kind: str) -> str: return "JIT guard missing" if flow_kind == "skim_sync": return "pool balance is reduced" + if flow_kind == "pair_inventory_burn": + return "existing pair inventory is burned" if flow_kind == "buy_sell": return "buy/sell accounting gap" if flow_kind == "margin_withdrawal": @@ -1543,6 +1695,8 @@ def compact_center_status(raw: str, fallback: str, flow_kind: str) -> str: return "fees harvested early" if flow_kind == "skim_sync": return "attacker cashes out" + if flow_kind == "pair_inventory_burn": + return "sync records distorted reserves" if flow_kind == "buy_sell": return "excess value payout" if flow_kind == "margin_withdrawal": @@ -1722,7 +1876,6 @@ def markdown_asset_loss_legs(text: str) -> list[dict[str, str]]: in_table = False for line in text.splitlines(): stripped = line.strip() - lowered = stripped.lower() if stripped.startswith("| Source | Direction | Holder | Role | Token | Delta | USD value |"): in_table = True continue @@ -1858,6 +2011,20 @@ def choose_flow_labels( "step4": "4. payout exceeds the checked value", "step5": "5. repeat buy/sell loop", } + if ( + "burn" in joined + and "sync" in joined + and ("pair inventory" in joined or "pair's existing" in joined or "pair balance" in joined) + ): + return { + "center_detail": "existing pair inventory is burned", + "center_status": "sync records distorted reserves", + "step1": "1. attacker sends tokens into the AMM pair", + "step2": "2. token logic burns the pair's existing inventory", + "step3": "3. pair sync records the depleted reserve", + "step4": "4. subsequent swaps extract the counter-asset", + "step5": "5. observed sell path", + } if "skim" in joined and "sync" in joined: return { "center_detail": "pool balance is reduced", @@ -1933,6 +2100,8 @@ def display_name(value: str) -> str: def surface_display_name(value: str) -> str: cleaned = clean_inline(value) + if re.fullmatch(r"0x[a-fA-F0-9]{40}", cleaned): + return "Affected contract" if " / " in cleaned: parts = [part.strip() for part in cleaned.split(" / ") if part.strip()] if len(parts) >= 2: @@ -2128,13 +2297,21 @@ def render_svg(data: dict[str, str | list[str]]) -> str: title = esc(data["title"]) subtitle = esc(data["subtitle"]) chain = esc(data["chain"]) - root_status_label_text = esc(data["root_status_label"]) + occurred = esc(data["occurred"]) + headline_loss = esc(data["headline_loss"]) + invariant_legend = ( + "Invariant link" if data["root_status"] == "confirmed" else "RCA / evidence gap" + ) center_title = svg_text_lines(data["center_title"], "node-title surface-title", 480, 178, 24, 25, 2) center_detail = svg_text_lines(data["center_detail"], "node-body", 480, 276, 20, 31, 2) center_failure = svg_text_lines("2. " + str(data["failure_step"]), "flow-step warn", 480, 370, 19, 36, 2) center_status = svg_text_lines(data["center_status"], "node-body loss", 480, 456, 20, 32, 2) actor_body = svg_text_lines(data["actor_detail"], "node-body accent", 96, 320, 20, 23, 3) asset_loss = svg_text_lines(data["asset_loss"], "node-body loss", 910, 322, 24, 22, 1) + loss_note = svg_text_lines(data["loss_note"], "node-note warn", 910, 343, 16, 28, 1) + affected_assets = svg_text_lines( + data["affected_assets"], "node-note", 910, 384, 22, 24, 1 + ) bottom_rule = svg_text_lines(data["bottom_rule"], "support-text", 96, 568, 20, 46, 3) step1_text = str(data["actor_edge"]) step3_text = str(data["value_edge"]) @@ -2146,7 +2323,8 @@ def render_svg(data: dict[str, str | list[str]]) -> str: - made by backwardlabs + BACKWARD LABS - + @@ -2199,7 +2376,8 @@ def render_svg(data: dict[str, str | list[str]]) -> str: {title} {subtitle} {chain} - {root_status_label_text} + {occurred} + {headline_loss} @@ -2223,12 +2401,15 @@ def render_svg(data: dict[str, str | list[str]]) -> str: {center_status} - + Asset movement Observed loss {asset_loss} +{loss_note} + Affected assets +{affected_assets} @@ -2249,13 +2430,13 @@ def render_svg(data: dict[str, str | list[str]]) -> str: Observed call - RCA / evidence gap + {invariant_legend} Failed assumption / impact - - Verified proof/status + BACKWARD LABS + """ diff --git a/skills/x-feed/skills/sanitize-exploit-report/SKILL.md b/skills/x-feed/skills/sanitize-exploit-report/SKILL.md index 7f1abfc..64cd26b 100644 --- a/skills/x-feed/skills/sanitize-exploit-report/SKILL.md +++ b/skills/x-feed/skills/sanitize-exploit-report/SKILL.md @@ -31,7 +31,9 @@ Create a public-safe brief from private exploit intelligence. Preserve the core 4. Produce the sanitized brief. - Use the output format below. - Mark uncertainty explicitly. - - Include a "do not publish" note for anything removed but important for internal review. + - Write the public-safe brief and internal redaction ledger as separate artifacts. + - Never pass the redaction ledger to drafting or rendering skills. + - When a visual is requested, create a separate public card packet using `../exploit-flow-card/references/public-card-packet.md`. ## Output Format @@ -67,13 +69,18 @@ Create a public-safe brief from private exploit intelligence. Preserve the core ### Safe Claims - [Claim] Confidence: high|medium|low. Basis: observed|inferred|reported. -### Removed or Abstracted -- [Sensitive detail type removed and why] - ### Drafting Notes - [Suggested angle, caveat, or audience note for the thread writer] ``` +Write removed material only to a separate internal artifact: + +```markdown +## Internal Redaction Ledger โ€” Do Not Pass Downstream + +- [Sensitive detail type removed, internal reason, and reviewer note] +``` + ## Diagram Guidance Prefer abstract diagrams over trace diagrams: