diff --git a/node/bcos_routes.py b/node/bcos_routes.py index a8e7f09ec..5d06e9d09 100644 --- a/node/bcos_routes.py +++ b/node/bcos_routes.py @@ -53,6 +53,21 @@ def _get_admin_key(): return os.environ.get("RC_ADMIN_KEY", "") +def _node_epoch_slot(): + """Current (epoch, slot) from the node's own /epoch endpoint; (0, 0) on + error. The main module is not importable (its filename contains dots) and + rip_200_round_robin_1cpu1vote has no current_slot, so we ask the running + node over localhost rather than duplicate its genesis/time constants.""" + try: + import urllib.request + with urllib.request.urlopen( + "http://127.0.0.1:8099/epoch", timeout=5) as r: + ej = json.loads(r.read().decode()) + return int(ej.get("epoch", 0)), int(ej.get("slot", 0)) + except Exception: + return 0, 0 + + def _bcos_public_url(path: str) -> str: """Build certificate-valid public BCOS URLs for API responses.""" base_url = ( @@ -176,7 +191,9 @@ def init_bcos_table(conn): report_json TEXT NOT NULL, signature TEXT, signer_pubkey TEXT, + attested_epoch INTEGER, anchored_epoch INTEGER, + anchor_tx TEXT, created_at INTEGER NOT NULL ) """) @@ -333,25 +350,22 @@ def bcos_attest(): now = int(time.time()) try: with sqlite3.connect(_DB_PATH) as conn: - # Calculate current epoch for anchoring - epoch = None - try: - from rip_200_round_robin_1cpu1vote import current_slot - epoch = current_slot() - except Exception: - pass + # Record the epoch this attestation was created in. anchored_epoch + # stays NULL here: it is set only when the cert is written to the + # ledger via /api/v1/bcos/anchor. + attested_epoch, _slot = _node_epoch_slot() conn.execute(""" INSERT OR REPLACE INTO bcos_attestations (cert_id, commitment, repo, commit_sha, tier, trust_score, reviewer, report_json, signature, signer_pubkey, - anchored_epoch, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + attested_epoch, anchored_epoch, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( cert_id, commitment, repo, commit_sha, tier, trust_score, reviewer, report_json_str, signature or None, signer_pubkey or None, - epoch, now, + attested_epoch, None, now, )) conn.commit() @@ -362,7 +376,8 @@ def bcos_attest(): "repo": repo, "tier": tier, "trust_score": trust_score, - "anchored_epoch": epoch, + "attested_epoch": attested_epoch, + "anchored_epoch": None, "verify_url": _bcos_public_url(f"/bcos/verify/{cert_id}"), "badge_url": _bcos_public_url(f"/bcos/badge/{cert_id}.svg"), }) @@ -421,7 +436,9 @@ def bcos_verify(cert_id): "trust_score": row["trust_score"], "tier_met": row["trust_score"] >= {"L0": 40, "L1": 60, "L2": 80}.get(row["tier"], 60), "reviewer": row["reviewer"], + "attested_epoch": (row["attested_epoch"] if "attested_epoch" in row.keys() else None), "anchored_epoch": row["anchored_epoch"], + "anchor_tx": (row["anchor_tx"] if "anchor_tx" in row.keys() else None), "created_at": row["created_at"], "score_breakdown": report.get("score_breakdown", {}), "checks": report.get("checks", {}), @@ -574,10 +591,125 @@ def bcos_directory(): return jsonify({"error": "internal_error"}), 500 +@bcos_bp.route("/api/v1/bcos/anchor", methods=["POST"]) +def bcos_anchor(): + """Anchor an existing BCOS attestation to the RustChain ledger. + + Records the certificate commitment as a zero-value memo entry in the + RustChain ledger (the chain's transaction history the explorer reads) and + stamps the attestation with the resulting tx hash and epoch. No RTC moves; + delta is zero. Admin-gated, and idempotent: a cert that is already anchored + returns its existing tx hash rather than writing a second ledger row. + """ + admin_key = request.headers.get("X-Admin-Key", "") + if not (admin_key and hmac.compare_digest(admin_key, _get_admin_key() or "")): + return jsonify({ + "error": "unauthorized", + "hint": "X-Admin-Key header matching RC_ADMIN_KEY required", + }), 401 + + data = request.get_json(silent=True) or {} + cert_id = data.get("cert_id", "") + if not cert_id or not isinstance(cert_id, str) or len(cert_id) > MAX_CERT_ID_LENGTH: + return jsonify({"error": "cert_id required"}), 400 + + try: + with sqlite3.connect(_DB_PATH) as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT cert_id, commitment, repo, commit_sha, tier, trust_score, " + "anchored_epoch, anchor_tx FROM bcos_attestations WHERE cert_id = ?", + (cert_id,), + ).fetchone() + if not row: + return jsonify({"error": "unknown_cert", "cert_id": cert_id}), 404 + + if row["anchor_tx"]: + return jsonify({ + "ok": True, + "already_anchored": True, + "cert_id": cert_id, + "commitment": row["commitment"], + "tx_hash": row["anchor_tx"], + "anchored_epoch": row["anchored_epoch"], + "ledger": "rustchain", + }) + + commitment = row["commitment"] + repo = row["repo"] + now = int(time.time()) + # Authoritative epoch/slot from the node's own /epoch computation. + epoch, slot = _node_epoch_slot() + + reason = json.dumps({ + "kind": "bcos_anchor", + "cert_id": cert_id, + "commitment": commitment, + "repo": repo, + "commit_sha": row["commit_sha"], + "tier": row["tier"], + "trust_score": row["trust_score"], + "epoch": epoch, + }, separators=(",", ":")) + tx_hash = blake2b( + ("bcos_anchor|" + cert_id + "|" + commitment + "|" + + str(epoch) + "|" + str(now)).encode(), + digest_size=32, + ).hexdigest() + + # Zero-value memo entry: records the anchor in the ledger without + # moving RTC or affecting any balance (delta_i64 = 0). The ledger + # epoch column stores the slot, matching the other ledger rows. + conn.execute( + "INSERT INTO ledger (ts, epoch, miner_id, delta_i64, reason) " + "VALUES (?, ?, ?, ?, ?)", + (now, slot, "bcos_registry", 0, reason), + ) + conn.execute( + "UPDATE bcos_attestations SET anchored_epoch = ?, anchor_tx = ? " + "WHERE cert_id = ?", + (epoch, tx_hash, cert_id), + ) + conn.commit() + + return jsonify({ + "ok": True, + "cert_id": cert_id, + "commitment": commitment, + "repo": repo, + "tx_hash": tx_hash, + "anchored_epoch": epoch, + "amount_rtc": 0, + "ledger": "rustchain", + "verify_url": _bcos_public_url(f"/bcos/verify/{cert_id}"), + }) + except Exception: + import logging + logging.exception("bcos_anchor failed") + return jsonify({"error": "internal_error"}), 500 + + # ── Registration ────────────────────────────────────────────────── +def _ensure_columns(db_path: str): + """Add newer bcos_attestations columns if an existing table predates them. + Each ALTER is idempotent: a duplicate-column error (or a not-yet-created + table, which init_bcos_table handles) is ignored.""" + for ddl in ( + "ALTER TABLE bcos_attestations ADD COLUMN attested_epoch INTEGER", + "ALTER TABLE bcos_attestations ADD COLUMN anchor_tx TEXT", + ): + try: + with sqlite3.connect(db_path) as conn: + conn.execute(ddl) + conn.commit() + except sqlite3.OperationalError: + pass + + def register_bcos_routes(app, db_path: str): """Register BCOS blueprint with the Flask app.""" global _DB_PATH _DB_PATH = db_path + _ensure_columns(db_path) app.register_blueprint(bcos_bp) diff --git a/site/nginx-rustchain-org.conf b/site/nginx-rustchain-org.conf index 2e40c264b..b4e97449a 100644 --- a/site/nginx-rustchain-org.conf +++ b/site/nginx-rustchain-org.conf @@ -670,6 +670,24 @@ server { + # BCOS v2 attestation registry (verify, attest, directory, cert, badge). + # ^~ so the badge SVG is proxied here and not captured by the static + # "\.svg$" regex location above (regex locations outrank plain prefixes). + # Without this block the routes 404 on rustchain.org even though the node + # serves them, because the catch-all below falls through to try_files. + location ^~ /bcos/ { + proxy_pass http://127.0.0.1:8099; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # BCOS on-chain anchor API (/api/v1/bcos/anchor and friends). + location /api/v1/bcos/ { + proxy_pass http://127.0.0.1:8099; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location / { try_files $uri $uri/ =404; } diff --git a/tools/bcos_engine.py b/tools/bcos_engine.py index dda4774a5..be7f7025b 100644 --- a/tools/bcos_engine.py +++ b/tools/bcos_engine.py @@ -299,43 +299,57 @@ def _check_dep_licenses(self) -> int: # ── Check 2: Vulnerability Scan (25 pts) ────────────────────── + # Directories that are not the project's own first-party source: vendored + # third-party trees, build output, VCS metadata, virtualenvs. + _NON_PROJECT_DIRS = ( + "node_modules", "venv", ".venv", "__pycache__", "build", "dist", + "target", "vendor", "third_party", "third-party", "amissl-sdk", + ) + + def _is_non_project(self, rel_parts) -> bool: + return any(p.startswith(".") or p in self._NON_PROJECT_DIRS + for p in rel_parts) + + def _python_requirement_files(self): + """The PROJECT's own pip requirement files, excluding vendored trees. + + Used so the vulnerability and freshness checks measure this project's + declared dependencies rather than the ambient Python environment of + whatever machine happens to run the scan. A C or retro project with no + such file has no declared Python dependency to audit.""" + found = [] + for pattern in ("requirements.txt", "requirements-*.txt", + "requirements/*.txt"): + for f in self.repo_path.rglob(pattern): + if not self._is_non_project(f.relative_to(self.repo_path).parts): + found.append(f) + return found + def _check_osv(self): - """Scan for known CVEs via pip-audit or osv-scanner.""" - critical = 0 - high = 0 - medium = 0 - low = 0 + """Scan the PROJECT's declared dependencies for known CVEs. + + Language-aware. Prefer osv-scanner against the repo tree: it reads + lockfiles and CycloneDX/SPDX SBOMs committed in the project, so it + covers C, Rust, Go, npm and more, not just Python. pip-audit is run + ONLY against the project's own requirements files. Neither tool is + ever pointed at the ambient Python environment of the scanning host. + A project that declares no dependencies genuinely has nothing that can + be vulnerable and is scored as clean, rather than inheriting whatever + unrelated packages are installed on the machine running the scan.""" + critical = high = medium = low = 0 vulns = [] - tool_used = None - - # Try pip-audit first (Python-focused) - rc, out, err = _run_cmd( - ["pip-audit", "--format=json", "--desc"], - timeout=180, - ) - if rc >= 0 and out.strip(): - tool_used = "pip-audit" - try: - data = json.loads(out) - for dep in data.get("dependencies", []): - for vuln in dep.get("vulns", []): - sev = vuln.get("fix_versions", []) - vid = vuln.get("id", "unknown") - # pip-audit doesn't always include severity - # Count each vuln as "high" unless we can determine otherwise - vulns.append({"id": vid, "package": dep.get("name", ""), "severity": "HIGH"}) - high += 1 - except json.JSONDecodeError: - pass + tools_used = [] + scanned = False - # Try osv-scanner as alternative - if tool_used is None: - rc, out, err = _run_cmd( + # 1. osv-scanner: language-agnostic, scans lockfiles + SBOM in the tree. + if shutil.which("osv-scanner"): + rc, out, _ = _run_cmd( ["osv-scanner", "--format", "json", str(self.repo_path)], timeout=180, ) if rc >= 0 and out.strip(): - tool_used = "osv-scanner" + tools_used.append("osv-scanner") + scanned = True try: data = json.loads(out) for result in data.get("results", []): @@ -343,9 +357,8 @@ def _check_osv(self): for v in pkg.get("vulnerabilities", []): severity = "MEDIUM" for sev in v.get("database_specific", {}).get("severity", []): - severity = sev.upper() - vid = v.get("id", "unknown") - vulns.append({"id": vid, "severity": severity}) + severity = str(sev).upper() + vulns.append({"id": v.get("id", "unknown"), "severity": severity}) if severity == "CRITICAL": critical += 1 elif severity == "HIGH": @@ -357,12 +370,46 @@ def _check_osv(self): except json.JSONDecodeError: pass - # Score: 25 base, -5/critical, -2/high + # 2. pip-audit: ONLY against the project's own requirement files. + req_files = self._python_requirement_files() + if req_files and shutil.which("pip-audit"): + for req in req_files: + rc, out, _ = _run_cmd( + ["pip-audit", "--format=json", "--desc", "-r", str(req)], + timeout=180, + ) + if rc >= 0 and out.strip(): + if "pip-audit" not in tools_used: + tools_used.append("pip-audit") + scanned = True + try: + data = json.loads(out) + deps = data.get("dependencies", []) if isinstance(data, dict) else data + for dep in deps: + for vuln in dep.get("vulns", []): + vulns.append({"id": vuln.get("id", "unknown"), + "package": dep.get("name", ""), + "severity": "HIGH"}) + high += 1 + except (json.JSONDecodeError, AttributeError): + pass + + # Score: 25 base, -5/critical, -2/high. No declared dependencies (a + # pure C/retro project) is a clean result, not an unmeasured one. pts = max(0, 25 - (critical * 5) - (high * 2)) + note = None + if not scanned: + if not req_files: + note = ("no declared dependency manifests in project; " + "nothing to be vulnerable") + else: + note = "no vulnerability scanner available (install osv-scanner)" + self.checks["vulnerability_scan"] = { "passed": critical == 0 and high == 0, - "tool": tool_used or "none_available", + "tools": tools_used or ["none_available"], + "scanned_scope": "project", "critical": critical, "high": high, "medium": medium, @@ -370,6 +417,8 @@ def _check_osv(self): "total_vulns": len(vulns), "vulns_sample": vulns[:20], } + if note: + self.checks["vulnerability_scan"]["note"] = note self.score_breakdown["vulnerability_scan"] = pts # ── Check 3: Static Analysis (20 pts) ───────────────────────── @@ -495,36 +544,45 @@ def _check_sbom(self): # ── Check 5: Dependency Freshness (5 pts) ───────────────────── def _check_dep_freshness(self): - """Check what percentage of deps are at latest version.""" - # Use pip list --outdated - rc, out, _ = _run_cmd( - ["pip", "list", "--outdated", "--format=json"], - timeout=60, - ) - rc2, out2, _ = _run_cmd( - ["pip", "list", "--format=json"], - timeout=60, - ) + """Freshness of the PROJECT's own declared dependencies. + + Only probes the pip environment when the project actually declares + Python requirements. A project with no dependency manifest (a pure + C/retro codebase) has nothing that can be out of date, so it is + vacuously fresh. This stops the check from reporting the staleness of + whatever unrelated packages happen to be installed on the scanning + host, which has nothing to do with the project being certified.""" + req_files = self._python_requirement_files() outdated = 0 total = 0 + note = None - if rc2 == 0 and out2.strip(): - try: - total = len(json.loads(out2)) - except Exception: - pass - - if rc == 0 and out.strip(): - try: - outdated = len(json.loads(out)) - except Exception: - pass - - if total > 0: - fresh_pct = ((total - outdated) / total) * 100 + if not req_files: + fresh_pct = 100.0 + note = ("no dependency manifests in project; " + "nothing to be outdated") else: - fresh_pct = 100 + # The project declares Python deps; measure them. pip list reflects + # the installed environment, which for a project-scoped scan is + # expected to be that project's own requirements. + rc, out, _ = _run_cmd( + ["pip", "list", "--outdated", "--format=json"], timeout=60) + rc2, out2, _ = _run_cmd( + ["pip", "list", "--format=json"], timeout=60) + + if rc2 == 0 and out2.strip(): + try: + total = len(json.loads(out2)) + except Exception: + pass + if rc == 0 and out.strip(): + try: + outdated = len(json.loads(out)) + except Exception: + pass + + fresh_pct = ((total - outdated) / total) * 100 if total > 0 else 100.0 pts = min(5, int(fresh_pct / 20)) @@ -534,6 +592,8 @@ def _check_dep_freshness(self): "outdated_deps": outdated, "fresh_pct": round(fresh_pct, 1), } + if note: + self.checks["dependency_freshness"]["note"] = note self.score_breakdown["dependency_freshness"] = pts # ── Check 6: Test Evidence (10 pts) ────────────────────────────