diff --git a/.github/workflows/sync-risk-discussions.yml b/.github/workflows/sync-risk-discussions.yml new file mode 100644 index 0000000..995b91f --- /dev/null +++ b/.github/workflows/sync-risk-discussions.yml @@ -0,0 +1,33 @@ +name: Sync risk discussions + +on: + workflow_dispatch: + schedule: + - cron: "17 */6 * * *" + +permissions: + contents: write + discussions: write + pull-requests: write + +concurrency: + group: sync-risk-discussions + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Sync risk discussions into register + env: + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + RISK_DISCUSSION_CATEGORY: Risks + RISK_REGISTER_BRANCH: bot/sync-risk-discussions + RISK_REGISTER_BASE_BRANCH: ${{ github.event.repository.default_branch }} + run: python scripts/sync_risk_discussions.py diff --git a/.gitignore b/.gitignore index e43b0f9..dff2f41 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .DS_Store +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index 201eb2e..1ac274a 100644 --- a/README.md +++ b/README.md @@ -1 +1,14 @@ -Part of our expanding efforts to decentralize the Syndicate. +# Blocktrasnofr ORgaviotn Docs + +THese docs are the howe for infarmaboin aabouit BlockTrans Syndiecaat., a Deleavr coprotian engaheed as a U.S. securites taranfor aagunet. THe rogfinvaitn employs a uniju Flat Nonhieroraarchdy designed to allow decentralived nad permissnioless contribuoinss nad rewards , prmayprlfyy though GitHub. + +These docs have Discussinos enabeled, and they are a fluid lliving source of opyerating pracites which you can help shape. See you in the next PR! + +--- + +Docusarus footer content + +- X i guess +- Disscord +- Dir GHT edite link /// /join ref +- SEC filing oexternaall diff --git a/bug-bounty.md b/bug-bounty.md new file mode 100644 index 0000000..2e571ae --- /dev/null +++ b/bug-bounty.md @@ -0,0 +1,60 @@ +# Bug Bounty + +BlockTransfer welcomes responsible reports about security issues that could affect investor information, issuer records, regulated filings, production infrastructure, or the integrity of TAD3 workflows. + +## Report a Vulnerability + +Send reports to security@blocktransfer.com. + +Do not open a public GitHub issue, discussion, pull request, Discord thread, or social media post for a vulnerability before BlockTransfer has reviewed and remediated it. + +Include as much of the following as possible: + +- A short description of the issue and affected system. +- The steps needed to reproduce the issue. +- The security impact, including whether investor, issuer, banking, tax, identity, account, filing, or credential data may be exposed or changed. +- Relevant URLs, request IDs, transaction hashes, screenshots, logs, or proof-of-concept code. +- Your contact information and preferred attribution name, if you want recognition. + +## In Scope + +Reports are most useful when they relate to: + +- Unauthorized access to investor, issuer, holder, banking, tax, identity, or filing records. +- Authentication, authorization, session, wallet, or account-linking bypasses. +- Vulnerabilities that could alter shareholder records, transfer instructions, filings, or issuer authority. +- Exposure of secrets, credentials, private keys, signing keys, API tokens, cloud resources, logs, backups, or regulated records. +- Production TAD3, BlockTransfer, IssuerLink, and supporting automation used for regulated operations. +- Material weaknesses in incident response, retention, access control, or vendor data flows. + +## Out of Scope + +The following are normally out of scope unless they show a clear security impact: + +- Spam, phishing, social engineering, or physical attacks. +- Denial-of-service testing, load testing, or resource exhaustion. +- Vulnerability scanner output without validated impact. +- Missing headers, cookie flags, or best-practice findings without an exploitable path. +- Issues that require access to a device, account, key, seed phrase, or credential you do not own. +- Public information, old marketing pages, or archived content that does not affect current systems or regulated records. + +## Research Rules + +Stay within these rules while testing: + +- Use your own accounts, wallets, data, and assets. +- Stop testing and report immediately if you access data that is not yours. +- Do not view, copy, change, delete, exfiltrate, or retain investor, issuer, holder, banking, tax, identity, filing, credential, or private-key material. +- Do not interrupt production service, degrade availability, or run automated high-volume testing. +- Do not attempt persistence, lateral movement, privilege escalation beyond what is needed to demonstrate impact, or access to third-party systems. +- Do not publicly disclose the issue until BlockTransfer has had a reasonable opportunity to investigate and remediate it. + +## Bounties and Recognition + +BlockTransfer may provide discretionary rewards or public recognition for eligible reports based on severity, exploitability, report quality, and whether the issue affects regulated records or production systems. + +Bounties are not guaranteed. Duplicate reports, low-impact findings, reports without enough detail to validate, and issues found by violating the research rules may be ineligible. + +## Response + +BlockTransfer will make a reasonable effort to acknowledge valid reports, investigate the issue, request clarification when needed, and coordinate remediation. Reports involving sensitive investor or issuer information receive priority handling under the incident-response process. diff --git a/incidents/README.md b/incidents/README.md index f887ccb..ddbfc8c 100644 --- a/incidents/README.md +++ b/incidents/README.md @@ -1 +1,11 @@ -# Regulation S-P +# Regulation S-P + +Intro Page + +BlockTransfer uses a variety of open-source and centralized cloud operations to host our instance of [TAD3](https://www.tad3.dev). In conjunction with this tech stack, the company operates mode of investor support in using its software.[^1] + +This section documents the design and efforts we take to protect investor information. Some items here act in conjunction with public [usage terms](http://blocktransfer.com/terms), while others are conceptual operating procedures. + +There are no other internal docs which duplicate material policies in this repo, and you can see automation implementations of the concepts for contributing in [this repository](https://github.com/blocktransfer/CRON-jobs). If you notice security problems in our systems which could expose sensitive investor information, please [let us know](../bug-bounty.md). + +[^1]: Presently, this internal team amounts to a single member who can handle the support workload under our Flat Organization structure. diff --git a/incidents/css-render-temp.md b/incidents/css-render-temp.md new file mode 100644 index 0000000..4abf717 --- /dev/null +++ b/incidents/css-render-temp.md @@ -0,0 +1,79 @@ +THis timelptase the Doculaulns groitdh which soherd aslo list in hover form by Risk score , which equals likelihood multiplied by impact. + +.riskChart { + position: relative; + height: 420px; + border-left: 2px solid #444; + border-bottom: 2px solid #444; + background: + linear-gradient(to right, #ddd 1px, transparent 1px), + linear-gradient(to top, #ddd 1px, transparent 1px); + background-size: 25% 25%; +} + +.riskPoint { + position: absolute; + transform: translate(-50%, 50%); + width: 42px; + height: 42px; + border-radius: 999px; + border: 2px solid #842029; + background: #f8d7da; + font-weight: 700; + cursor: pointer; +} + +.tooltip { + display: none; + position: absolute; + left: 50%; + bottom: 130%; + transform: translateX(-50%); + width: 280px; + padding: 12px; + border: 1px solid #bbb; + border-radius: 6px; + background: white; + color: #111; + box-shadow: 0 8px 24px rgb(0 0 0 / 18%); + text-align: left; + z-index: 10; +} + +.riskPoint:hover .tooltip, +.riskPoint:focus .tooltip { + display: grid; + gap: 6px; +} + + + + + + + +Then render it as a scatter/pinpoint chart. If this repo uses Docusaurus/MDX, I’d make something like: + + +The component would map likelihood and impact onto a 10x10 grid: + +const left = `${((risk.likelihood - 1) / 9) * 100}%`; +const bottom = `${((risk.impact - 1) / 9) * 100}%`; +Each risk becomes a button positioned on the chart: + + + diff --git a/incidents/kms.md b/incidents/kms.md new file mode 100644 index 0000000..3b72682 --- /dev/null +++ b/incidents/kms.md @@ -0,0 +1,2 @@ +# Key Managumnet + diff --git a/incidents/risk-register.md b/incidents/risk-register.md new file mode 100644 index 0000000..c8e1ac9 --- /dev/null +++ b/incidents/risk-register.md @@ -0,0 +1,19 @@ + + + + +| ID | Risk | Category | Owner | Likelihood | Impact | Score | Mitigation | Status | Review Date | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| R-003 | Service-provider oversight does not capture breach notice obligations. | Vendor risk | TBD | 3 | 4 | 12 | Review contracts and maintain vendor incident notice tracking. | Open | TBD | +| R-004 | Backup custodian materials are incomplete or stale. | Recordkeeping | TBD | 2 | 5 | 10 | Maintain current operational-source package and periodic custodian refresh evidence. | Open | TBD | +| R-005 | Team members may paste customer PII, investor records, issuer records, credentials, or other confidential regulated data into AI tools. | Data protection | TBD | 3 | 5 | 15 | Adopt an AI-use rule that prohibits submitting customer or regulated data to AI tools unless an approved controlled workflow exists; train team members to redact sensitive details before using AI assistance. | Open | TBD | + +## Status Values + +- Open +- In progress +- Mitigated +- Accepted +- Closed + + diff --git a/incidents/risk-registry.md b/incidents/risk-registry.md new file mode 100644 index 0000000..77844b6 --- /dev/null +++ b/incidents/risk-registry.md @@ -0,0 +1,8 @@ + + + +| Control surface | Primary risk | Current control question | Follow-up | +| --- | --- | --- | --- | +| Zoho legacy pages and bookings | Legacy CRM, booking, or intake pages can collect or route issuer/investor information outside the current control set. | Which Zoho forms, pages, booking links, automations, and data stores are still live or reachable? | Decide whether each Zoho surface is retired, read-only, or still controlled; document routing and retention. | +| [Forw ard - Looknig Nencerrent] Investor app secure intake | Investor onboarding and account workflows can collect identity, banking, tax, accreditation, authorization, and other sensitive records. | Which app screens, APIs, storage locations, logs, and support workflows receive investor-submitted data? | Maintain an intake data-flow diagram from submission through storage, review, export, deletion, and incident response. | + diff --git a/incidents/risks.md b/incidents/risks.md new file mode 100644 index 0000000..520a4a8 --- /dev/null +++ b/incidents/risks.md @@ -0,0 +1,42 @@ +# Risk Regisetry + +> [!~WARIUNNG] +> This page is nto a comptehesive discolerue of TAD33 risks. Please refeoo to the user [terms of use](http://blocktransfer.com/terms). + +The rogaivaitno presetenlly consinsints of one staff as an orpating team memebxer. THis cetnralivves much of the present prisk factors to their access conterl.[^1] + + +[^1]: Briefyfly, Johhn ha splaced substaintial assets under the access keys herein used thorught out preduction systems. Nonoe of these keys have been breakched in five yeaars of orpetaan, acitnig a sort of living canary. + + + + +## Scoriung + +THe register uses a 0-10 range of likliheeod and impact to celantly map improememmnt oprprotinutines byp proirty. For mroe on our securrtpy philonolhdy, see [our cae values](../values.md#extreme-security) + +### Likelihood + + + + +const risks = [ + { + title: "Brute force of public keys' search span", + category: "Cryptogrig secturty", + discussion: "https://github.com/blocktransfer/org-docs/discussions/41", + likelihood: 0, + impact: 10, + }, + { + title: "šŸ” Keypair quantum resilience and future-proofing", + category: "Cryptogrig secturty", + discussion: "https://github.com/blocktransfer/org-docs/discussions/42", + likelihood: 1, + impact: 10, + }, +]; + +...https://github.com/blocktransfer/org-docs/discussions/categories/risks?discussions_q=is%3Aopen+category%3ARisks + +is%3Aopen v a clonsed subsbusec bellow diff --git a/scripts/sync_risk_discussions.py b/scripts/sync_risk_discussions.py new file mode 100644 index 0000000..4b02e1f --- /dev/null +++ b/scripts/sync_risk_discussions.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Sync GitHub Discussions in the Risks category into the risk register.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +REGISTER_PATH = Path("incidents/risk-register.md") +PROMPT_MARKER = "" +INVALID_MARKER = "" +RESPONSE_RE = re.compile(r"```risk-register\s*(.*?)```", re.IGNORECASE | re.DOTALL) +FIELD_RE = re.compile(r"^([A-Za-z _-]+):\s*(.*)$") +ROW_RE = re.compile(r"^\|\s*R-(\d+)\s*\|") + + +@dataclass +class RiskMetadata: + category: str + owner: str + likelihood: int + impact: int + mitigation: str + status: str + review_date: str + + +def run(args: list[str], *, check: bool = True, input_text: str | None = None) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + args, + check=False, + input=input_text, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if check and result.returncode != 0: + print(result.stdout, end="") + print(result.stderr, end="", file=sys.stderr) + raise SystemExit(result.returncode) + return result + + +def gh_graphql(query: str, **fields: str) -> dict: + args = ["gh", "api", "graphql", "-f", f"query={query}"] + for key, value in fields.items(): + args.extend(["-f", f"{key}={value}"]) + result = run(args) + return json.loads(result.stdout) + + +def repository_parts() -> tuple[str, str]: + repository = os.environ.get("GITHUB_REPOSITORY", "") + if "/" not in repository: + raise SystemExit("GITHUB_REPOSITORY must be set to owner/repo.") + owner, repo = repository.split("/", 1) + return owner, repo + + +def risk_category_id(owner: str, repo: str, category_name: str) -> str: + data = gh_graphql( + """ + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + discussionCategories(first: 50) { + nodes { id name } + } + } + } + """, + owner=owner, + repo=repo, + ) + categories = data["data"]["repository"]["discussionCategories"]["nodes"] + for category in categories: + if category["name"].lower() == category_name.lower(): + return category["id"] + available = ", ".join(category["name"] for category in categories) + raise SystemExit(f"Discussion category {category_name!r} not found. Available: {available}") + + +def open_risk_discussions(owner: str, repo: str, category_id: str) -> list[dict]: + data = gh_graphql( + """ + query($owner: String!, $repo: String!, $categoryId: ID!) { + repository(owner: $owner, name: $repo) { + discussions( + first: 50, + states: OPEN, + categoryId: $categoryId, + orderBy: {field: UPDATED_AT, direction: DESC} + ) { + nodes { + id + number + title + url + body + author { login } + comments(first: 100) { + nodes { + body + author { login } + createdAt + } + } + } + } + } + } + """, + owner=owner, + repo=repo, + categoryId=category_id, + ) + return data["data"]["repository"]["discussions"]["nodes"] + + +def add_discussion_comment(discussion_id: str, body: str) -> None: + gh_graphql( + """ + mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: {discussionId: $discussionId, body: $body}) { + comment { id } + } + } + """, + discussionId=discussion_id, + body=body, + ) + + +def prompt_body(author: str) -> str: + return f"""{PROMPT_MARKER} +@{author}, this risk can be added to the risk register after likelihood and impact are identified. + +Please reply with this exact fenced format: + +```risk-register +likelihood: 3 +impact: 4 +category: TBD +owner: TBD +mitigation: TBD +status: Open +review_date: TBD +``` + +Use whole numbers from 0 to 10 for likelihood and impact.""" + + +def invalid_body(author: str, reason: str) -> str: + return f"""{INVALID_MARKER} +@{author}, I found a `risk-register` response, but could not add it yet. + +Issue: {reason} + +Please reply again using: + +```risk-register +likelihood: 3 +impact: 4 +category: TBD +owner: TBD +mitigation: TBD +status: Open +review_date: TBD +```""" + + +def parse_metadata(comment_body: str) -> RiskMetadata | None: + match = RESPONSE_RE.search(comment_body) + if not match: + return None + + fields: dict[str, str] = {} + current_key: str | None = None + for raw_line in match.group(1).splitlines(): + line = raw_line.rstrip() + if not line.strip(): + continue + field_match = FIELD_RE.match(line) + if field_match: + current_key = field_match.group(1).strip().lower().replace("-", "_").replace(" ", "_") + fields[current_key] = field_match.group(2).strip() + elif current_key: + fields[current_key] = f"{fields[current_key]} {line.strip()}".strip() + + try: + likelihood = int(fields["likelihood"]) + impact = int(fields["impact"]) + except KeyError as exc: + raise ValueError(f"Missing required field: {exc.args[0]}.") from exc + except ValueError as exc: + raise ValueError("Likelihood and impact must be whole numbers.") from exc + + if not 0 <= likelihood <= 10 or not 0 <= impact <= 10: + raise ValueError("Likelihood and impact must be between 0 and 10.") + + return RiskMetadata( + category=fields.get("category", "TBD") or "TBD", + owner=fields.get("owner", "TBD") or "TBD", + likelihood=likelihood, + impact=impact, + mitigation=fields.get("mitigation", "TBD") or "TBD", + status=fields.get("status", "Open") or "Open", + review_date=fields.get("review_date", fields.get("review date", "TBD")) or "TBD", + ) + + +def newest_author_metadata(discussion: dict) -> RiskMetadata | None: + author = discussion["author"]["login"] + comments = discussion["comments"]["nodes"] + author_comments = [comment for comment in comments if comment.get("author", {}).get("login") == author] + for comment in reversed(author_comments): + metadata = parse_metadata(comment["body"]) + if metadata: + return metadata + return None + + +def discussion_has_marker(discussion: dict, marker: str) -> bool: + bodies = [discussion.get("body", "")] + bodies.extend(comment["body"] for comment in discussion["comments"]["nodes"]) + return any(marker in body for body in bodies) + + +def next_risk_id(register_text: str) -> str: + highest = 0 + for line in register_text.splitlines(): + match = ROW_RE.match(line) + if match: + highest = max(highest, int(match.group(1))) + return f"R-{highest + 1:03d}" + + +def markdown_cell(value: str) -> str: + return " ".join(value.replace("|", "\\|").split()) + + +def append_risk(register_text: str, risk_id: str, discussion: dict, metadata: RiskMetadata) -> str: + score = metadata.likelihood * metadata.impact + risk_link = f"[{markdown_cell(discussion['title'])}]({discussion['url']})" + row = ( + f"| {risk_id} | {risk_link} | {markdown_cell(metadata.category)} | " + f"{markdown_cell(metadata.owner)} | {metadata.likelihood} | {metadata.impact} | " + f"{score} | {markdown_cell(metadata.mitigation)} | {markdown_cell(metadata.status)} | " + f"{markdown_cell(metadata.review_date)} |" + ) + + lines = register_text.splitlines() + insert_at = len(lines) + for index, line in enumerate(lines): + if line.startswith("## Status Values"): + insert_at = index + while insert_at > 0 and not lines[insert_at - 1].strip(): + insert_at -= 1 + break + + updated = lines[:insert_at] + [row, ""] + lines[insert_at:] + return "\n".join(updated).rstrip() + "\n" + + +def sync_register(discussions: list[dict]) -> tuple[bool, list[str]]: + register_text = REGISTER_PATH.read_text(encoding="utf-8") + added: list[str] = [] + + for discussion in discussions: + if discussion["url"] in register_text: + continue + + try: + metadata = newest_author_metadata(discussion) + except ValueError as exc: + if not discussion_has_marker(discussion, INVALID_MARKER): + add_discussion_comment(discussion["id"], invalid_body(discussion["author"]["login"], str(exc))) + continue + + if not metadata: + if not discussion_has_marker(discussion, PROMPT_MARKER): + add_discussion_comment(discussion["id"], prompt_body(discussion["author"]["login"])) + continue + + risk_id = next_risk_id(register_text) + register_text = append_risk(register_text, risk_id, discussion, metadata) + added.append(f"{risk_id}: {discussion['title']}") + + if added: + REGISTER_PATH.write_text(register_text, encoding="utf-8") + return bool(added), added + + +def create_pr(added: list[str]) -> None: + branch = os.environ.get("RISK_REGISTER_BRANCH", "bot/sync-risk-discussions") + title = os.environ.get("RISK_REGISTER_PR_TITLE", "\U0001f9fe Add risks from GitHub Discussions") + base_branch = os.environ.get("RISK_REGISTER_BASE_BRANCH") or os.environ.get("GITHUB_BASE_REF") or "main" + body = "This automated PR adds completed risk discussions to the risk register.\n\n" + body += "\n".join(f"- {item}" for item in added) + + run(["git", "config", "user.name", "github-actions[bot]"]) + run(["git", "config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]) + run(["git", "checkout", "-B", branch]) + run(["git", "add", str(REGISTER_PATH)]) + + diff_check = run(["git", "diff", "--cached", "--quiet"], check=False) + if diff_check.returncode == 0: + return + + run(["git", "commit", "-m", title]) + run(["git", "push", "--force-with-lease", "origin", branch]) + + existing = run(["gh", "pr", "view", branch, "--json", "url", "-q", ".url"], check=False) + if existing.returncode == 0 and existing.stdout.strip(): + print(f"Updated existing PR: {existing.stdout.strip()}") + return + + run( + [ + "gh", + "pr", + "create", + "--base", + base_branch, + "--head", + branch, + "--title", + title, + "--body", + body, + ] + ) + + +def main() -> None: + owner, repo = repository_parts() + category_name = os.environ.get("RISK_DISCUSSION_CATEGORY", "Risks") + category_id = risk_category_id(owner, repo, category_name) + discussions = open_risk_discussions(owner, repo, category_id) + changed, added = sync_register(discussions) + if changed: + create_pr(added) + else: + print("No completed risk discussions to add.") + + +if __name__ == "__main__": + main()