From 562df87bf6818e3aef809595564499d5d46f00c0 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:20:54 +0100 Subject: [PATCH 01/15] =?UTF-8?q?feat(audit):=20add=20STX-001=20rule=20?= =?UTF-8?q?=E2=80=94=20missing=20asserts!=20before=20state-changing=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/audit/rules/stx-001-missing-asserts.js | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/audit/rules/stx-001-missing-asserts.js diff --git a/src/audit/rules/stx-001-missing-asserts.js b/src/audit/rules/stx-001-missing-asserts.js new file mode 100644 index 0000000..58ac7fa --- /dev/null +++ b/src/audit/rules/stx-001-missing-asserts.js @@ -0,0 +1,65 @@ +// STX-001: Missing asserts! before state-changing operations +// Severity: Critical +// +// Detects define-public functions that contain map-set, var-set, ft-transfer?, +// ft-mint?, ft-burn?, nft-mint?, nft-transfer?, or nft-burn? without a +// preceding asserts! or try! guard in the same function body. + +const RULE_ID = 'STX-001'; +const SEVERITY = 'critical'; +const DESCRIPTION = 'State-changing operation without a preceding asserts! guard'; + +const STATE_CHANGERS = [ + 'map-set', 'map-delete', 'var-set', + 'ft-transfer?', 'ft-mint?', 'ft-burn?', + 'nft-mint?', 'nft-transfer?', 'nft-burn?', + 'stx-transfer?', +]; + +function check(lines) { + const findings = []; + let insidePublic = false; + let functionStart = 0; + let hasGuard = false; + let depth = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (/\(define-public/.test(trimmed)) { + insidePublic = true; + functionStart = i + 1; + hasGuard = false; + depth = 0; + } + + if (insidePublic) { + depth += (line.match(/\(/g) || []).length; + depth -= (line.match(/\)/g) || []).length; + + if (/asserts!|try!/.test(trimmed)) { + hasGuard = true; + } + + for (const changer of STATE_CHANGERS) { + if (trimmed.includes(changer) && !hasGuard) { + findings.push({ + ruleId: RULE_ID, + severity: SEVERITY, + line: i + 1, + message: `${DESCRIPTION}: \`${changer}\` found without preceding \`asserts!\``, + }); + } + } + + if (depth <= 0 && i > functionStart) { + insidePublic = false; + } + } + } + + return findings; +} + +module.exports = { RULE_ID, SEVERITY, DESCRIPTION, check }; From 8b4912440a3f203e195dae1e41264ab8347c48e6 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:21:26 +0100 Subject: [PATCH 02/15] =?UTF-8?q?feat(audit):=20add=20STX-002=20rule=20?= =?UTF-8?q?=E2=80=94=20unchecked=20contract-call=3F=20return=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rules/stx-002-unchecked-contract-call.js | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/audit/rules/stx-002-unchecked-contract-call.js diff --git a/src/audit/rules/stx-002-unchecked-contract-call.js b/src/audit/rules/stx-002-unchecked-contract-call.js new file mode 100644 index 0000000..3e36e1d --- /dev/null +++ b/src/audit/rules/stx-002-unchecked-contract-call.js @@ -0,0 +1,41 @@ +// STX-002: Unchecked contract-call? return values +// Severity: Critical +// +// Detects contract-call? invocations whose response is not wrapped in +// try!, unwrap!, unwrap-panic!, match, or is-ok / is-err. + +const RULE_ID = 'STX-002'; +const SEVERITY = 'critical'; +const DESCRIPTION = 'contract-call? return value is not checked'; + +function check(lines) { + const findings = []; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + + if (!trimmed.includes('contract-call?')) continue; + if (trimmed.startsWith(';;')) continue; // skip comments + + const hasCheck = + /try!\s*\(contract-call\?/.test(trimmed) || + /unwrap!\s*\(contract-call\?/.test(trimmed) || + /unwrap-panic!\s*\(contract-call\?/.test(trimmed) || + /match\s*\(contract-call\?/.test(trimmed) || + /is-ok\s*\(contract-call\?/.test(trimmed) || + /is-err\s*\(contract-call\?/.test(trimmed); + + if (!hasCheck) { + findings.push({ + ruleId: RULE_ID, + severity: SEVERITY, + line: i + 1, + message: `${DESCRIPTION} — wrap with try!, unwrap!, or match`, + }); + } + } + + return findings; +} + +module.exports = { RULE_ID, SEVERITY, DESCRIPTION, check }; From 4083ab1b793b10b3899294e45b6b383f7ffce10e Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:21:58 +0100 Subject: [PATCH 03/15] feat(audit): add STX-003 tx-sender misuse and STX-004 unbounded iteration rules --- src/audit/rules/stx-003-tx-sender-misuse.js | 42 +++++++++++++++ .../rules/stx-004-unbounded-iteration.js | 52 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/audit/rules/stx-003-tx-sender-misuse.js create mode 100644 src/audit/rules/stx-004-unbounded-iteration.js diff --git a/src/audit/rules/stx-003-tx-sender-misuse.js b/src/audit/rules/stx-003-tx-sender-misuse.js new file mode 100644 index 0000000..060e599 --- /dev/null +++ b/src/audit/rules/stx-003-tx-sender-misuse.js @@ -0,0 +1,42 @@ +// STX-003: Use of tx-sender instead of contract-caller in sensitive contexts +// Severity: Warning +// +// When a public function is called via another contract, tx-sender is the +// originating user but contract-caller is the calling contract. +// Using tx-sender for authorization in contract-callable functions +// can allow spoofing in composable contexts. + +const RULE_ID = 'STX-003'; +const SEVERITY = 'warning'; +const DESCRIPTION = 'tx-sender used for authorization — consider contract-caller in composable contexts'; + +// Patterns that indicate authorization checks using tx-sender +const AUTH_PATTERNS = [ + /is-eq\s+tx-sender/, + /asserts!.*tx-sender/, +]; + +function check(lines) { + const findings = []; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith(';;')) continue; + + for (const pattern of AUTH_PATTERNS) { + if (pattern.test(trimmed)) { + findings.push({ + ruleId: RULE_ID, + severity: SEVERITY, + line: i + 1, + message: `${DESCRIPTION}`, + }); + break; // one finding per line + } + } + } + + return findings; +} + +module.exports = { RULE_ID, SEVERITY, DESCRIPTION, check }; diff --git a/src/audit/rules/stx-004-unbounded-iteration.js b/src/audit/rules/stx-004-unbounded-iteration.js new file mode 100644 index 0000000..43a1414 --- /dev/null +++ b/src/audit/rules/stx-004-unbounded-iteration.js @@ -0,0 +1,52 @@ +// STX-004: Unbounded fold or map over user-supplied lists +// Severity: Warning +// +// Clarity's fold and map are bounded by the list definition, but when +// the list comes from a function argument, cost can be unpredictable. +// Flag public functions that accept a list parameter AND use fold/map. + +const RULE_ID = 'STX-004'; +const SEVERITY = 'warning'; +const DESCRIPTION = 'Unbounded fold/map over a list parameter — ensure input size is constrained'; + +function check(lines) { + const findings = []; + let insidePublic = false; + let hasListParam = false; + let depth = 0; + let functionStart = 0; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + + if (/\(define-public/.test(trimmed)) { + insidePublic = true; + functionStart = i + 1; + hasListParam = /(list\s+\d+|list\s+\()/.test(trimmed) || /\(list /.test(trimmed); + depth = 0; + } + + if (insidePublic) { + depth += (lines[i].match(/\(/g) || []).length; + depth -= (lines[i].match(/\)/g) || []).length; + + if (i > functionStart && hasListParam && /\b(fold|map)\b/.test(trimmed)) { + findings.push({ + ruleId: RULE_ID, + severity: SEVERITY, + line: i + 1, + message: `${DESCRIPTION}`, + }); + } + + if (depth <= 0 && i > functionStart) { + insidePublic = false; + hasListParam = false; + } + } + } + + return findings; +} + +module.exports = { RULE_ID, SEVERITY, DESCRIPTION, check }; From 39dd5f5505e0a206ff9cdf931d0081f6b4fdd509 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:24:48 +0100 Subject: [PATCH 04/15] feat(audit): add STX-005 missing docs and STX-006 no principal check rules --- src/audit/rules/stx-005-missing-docs.js | 46 +++++++++++++ src/audit/rules/stx-006-no-principal-check.js | 69 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/audit/rules/stx-005-missing-docs.js create mode 100644 src/audit/rules/stx-006-no-principal-check.js diff --git a/src/audit/rules/stx-005-missing-docs.js b/src/audit/rules/stx-005-missing-docs.js new file mode 100644 index 0000000..326f5ca --- /dev/null +++ b/src/audit/rules/stx-005-missing-docs.js @@ -0,0 +1,46 @@ +// STX-005: Missing doc annotations on public functions +// Severity: Info +// +// Public functions that do not have a ;; @doc or ;; description comment +// immediately preceding them are flagged. Good documentation is essential +// for auditability and developer experience. + +const RULE_ID = 'STX-005'; +const SEVERITY = 'info'; +const DESCRIPTION = 'Public function is missing a documentation comment'; + +function check(lines) { + const findings = []; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + + if (!/^\(define-public/.test(trimmed)) continue; + + // Extract function name + const nameMatch = trimmed.match(/\(define-public\s+\((\S+)/); + const fnName = nameMatch ? nameMatch[1] : 'unknown'; + + // Check the previous non-empty line for a comment + let prevIdx = i - 1; + while (prevIdx >= 0 && lines[prevIdx].trim() === '') { + prevIdx--; + } + + const prevLine = prevIdx >= 0 ? lines[prevIdx].trim() : ''; + const hasDoc = prevLine.startsWith(';;'); + + if (!hasDoc) { + findings.push({ + ruleId: RULE_ID, + severity: SEVERITY, + line: i + 1, + message: `${DESCRIPTION}: \`${fnName}\` — add a ;; comment above it`, + }); + } + } + + return findings; +} + +module.exports = { RULE_ID, SEVERITY, DESCRIPTION, check }; diff --git a/src/audit/rules/stx-006-no-principal-check.js b/src/audit/rules/stx-006-no-principal-check.js new file mode 100644 index 0000000..1cdf8f6 --- /dev/null +++ b/src/audit/rules/stx-006-no-principal-check.js @@ -0,0 +1,69 @@ +// STX-006: Public functions with no principal validation +// Severity: Warning +// +// Detects define-public functions that do not contain any principal +// validation: no is-eq tx-sender, no is-eq contract-caller, no asserts! +// involving a principal. Such functions are callable by anyone, which +// may be intentional but should be reviewed. + +const RULE_ID = 'STX-006'; +const SEVERITY = 'warning'; +const DESCRIPTION = 'Public function has no principal validation — verify this is intentional'; + +const PRINCIPAL_CHECKS = [ + /is-eq\s+tx-sender/, + /is-eq\s+contract-caller/, + /asserts!.*principal/, + /is-eq.*CONTRACT-OWNER/, + /is-eq.*contract-caller/, +]; + +function check(lines) { + const findings = []; + let insidePublic = false; + let functionStart = 0; + let fnName = ''; + let hasPrincipalCheck = false; + let depth = 0; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + + if (/^\(define-public/.test(trimmed)) { + insidePublic = true; + functionStart = i + 1; + hasPrincipalCheck = false; + depth = 0; + const m = trimmed.match(/\(define-public\s+\((\S+)/); + fnName = m ? m[1] : 'unknown'; + } + + if (insidePublic) { + depth += (lines[i].match(/\(/g) || []).length; + depth -= (lines[i].match(/\)/g) || []).length; + + for (const pattern of PRINCIPAL_CHECKS) { + if (pattern.test(trimmed)) { + hasPrincipalCheck = true; + break; + } + } + + if (depth <= 0 && i > functionStart) { + if (!hasPrincipalCheck) { + findings.push({ + ruleId: RULE_ID, + severity: SEVERITY, + line: functionStart, + message: `${DESCRIPTION}: \`${fnName}\``, + }); + } + insidePublic = false; + } + } + } + + return findings; +} + +module.exports = { RULE_ID, SEVERITY, DESCRIPTION, check }; From ca4212e5b5d9fabd90219b3d7e7203b51b9a7ea7 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:28:45 +0100 Subject: [PATCH 05/15] feat(audit): register all 6 rules in rules index --- src/audit/rules/index.js | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/audit/rules/index.js diff --git a/src/audit/rules/index.js b/src/audit/rules/index.js new file mode 100644 index 0000000..28b171e --- /dev/null +++ b/src/audit/rules/index.js @@ -0,0 +1,10 @@ +const stx001 = require('./stx-001-missing-asserts'); +const stx002 = require('./stx-002-unchecked-contract-call'); +const stx003 = require('./stx-003-tx-sender-misuse'); +const stx004 = require('./stx-004-unbounded-iteration'); +const stx005 = require('./stx-005-missing-docs'); +const stx006 = require('./stx-006-no-principal-check'); + +const ALL_RULES = [stx001, stx002, stx003, stx004, stx005, stx006]; + +module.exports = { ALL_RULES }; From b6e10cdbf4ea24b37131d7d49d95245eb2530f04 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:29:12 +0100 Subject: [PATCH 06/15] feat(audit): implement contract scanner that applies all rules and summarizes findings --- src/audit/scanner.js | 51 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/audit/scanner.js diff --git a/src/audit/scanner.js b/src/audit/scanner.js new file mode 100644 index 0000000..3b15153 --- /dev/null +++ b/src/audit/scanner.js @@ -0,0 +1,51 @@ +const fs = require('fs-extra'); +const path = require('path'); +const { ALL_RULES } = require('./rules'); + +async function scanContracts(contractsDir) { + const results = []; + + if (!(await fs.pathExists(contractsDir))) { + return results; + } + + const files = (await fs.readdir(contractsDir)) + .filter((f) => f.endsWith('.clar')) + .map((f) => path.join(contractsDir, f)); + + for (const filePath of files) { + const source = await fs.readFile(filePath, 'utf8'); + const lines = source.split('\n'); + const findings = []; + + for (const rule of ALL_RULES) { + const found = rule.check(lines); + findings.push(...found); + } + + results.push({ + file: path.relative(process.cwd(), filePath), + findings: findings.sort((a, b) => a.line - b.line), + }); + } + + return results; +} + +function summarize(results) { + let critical = 0; + let warning = 0; + let info = 0; + + for (const { findings } of results) { + for (const f of findings) { + if (f.severity === 'critical') critical++; + else if (f.severity === 'warning') warning++; + else if (f.severity === 'info') info++; + } + } + + return { critical, warning, info }; +} + +module.exports = { scanContracts, summarize }; From 008c28b5657e033172223e3d584530d593f90355 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:29:30 +0100 Subject: [PATCH 07/15] feat(audit): implement terminal and JSON reporters for audit findings --- src/audit/reporter.js | 52 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/audit/reporter.js diff --git a/src/audit/reporter.js b/src/audit/reporter.js new file mode 100644 index 0000000..ee26afd --- /dev/null +++ b/src/audit/reporter.js @@ -0,0 +1,52 @@ +const chalk = require('chalk'); + +const SEVERITY_LABEL = { + critical: chalk.red.bold('CRIT'), + warning: chalk.yellow('WARN'), + info: chalk.cyan('INFO'), +}; + +function printReport(results, summary) { + console.log(''); + + if (results.length === 0) { + console.log(chalk.yellow('No .clar contracts found in contracts/ directory.')); + return; + } + + for (const { file, findings } of results) { + console.log(chalk.bold.underline(file)); + + if (findings.length === 0) { + console.log(` ${chalk.green('✓')} No issues found`); + } else { + for (const f of findings) { + const label = SEVERITY_LABEL[f.severity] || f.severity.toUpperCase(); + console.log(` ${label} [line ${f.line}] ${chalk.dim(f.ruleId)} ${f.message}`); + } + } + + console.log(''); + } + + const { critical, warning, info } = summary; + const critStr = critical > 0 ? chalk.red.bold(`${critical} critical`) : `${critical} critical`; + const warnStr = warning > 0 ? chalk.yellow(`${warning} warning`) : `${warning} warning`; + const infoStr = chalk.cyan(`${info} info`); + + console.log(chalk.bold(`Audit complete: ${critStr}, ${warnStr}, ${infoStr}`)); + console.log(''); +} + +function printJsonReport(results, summary) { + const output = { + summary, + contracts: results.map(({ file, findings }) => ({ + file, + findings, + })), + }; + console.log(JSON.stringify(output, null, 2)); +} + +module.exports = { printReport, printJsonReport }; From 794eff2630ae52e7081e78c19c28aef50af5f27d Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:29:47 +0100 Subject: [PATCH 08/15] feat(audit): implement audit command handler with CI exit code support --- src/commands/audit.js | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/commands/audit.js diff --git a/src/commands/audit.js b/src/commands/audit.js new file mode 100644 index 0000000..ffea1d2 --- /dev/null +++ b/src/commands/audit.js @@ -0,0 +1,42 @@ +const path = require('path'); +const chalk = require('chalk'); +const ora = require('ora'); +const { scanContracts, summarize } = require('../audit/scanner'); +const { printReport, printJsonReport } = require('../audit/reporter'); + +async function audit(opts = {}) { + const contractsDir = path.join(process.cwd(), 'contracts'); + const useJson = opts.json === true; + + if (!useJson) { + console.log(chalk.cyan('\n✦ stxforge audit — Clarity Security Checklist\n')); + } + + const spinner = useJson ? null : ora('Scanning contracts...').start(); + + try { + const results = await scanContracts(contractsDir); + + if (spinner) spinner.stop(); + + const summary = summarize(results); + + if (useJson) { + printJsonReport(results, summary); + } else { + const totalFiles = results.length; + console.log(chalk.dim(`Auditing ${totalFiles} contract${totalFiles !== 1 ? 's' : ''}...\n`)); + printReport(results, summary); + } + + // Exit code 1 when critical issues are found (CI-friendly) + if (summary.critical > 0) { + process.exitCode = 1; + } + } catch (err) { + if (spinner) spinner.fail(chalk.red('Audit failed')); + throw err; + } +} + +module.exports = { audit }; From aba42d10cbfdfc51600186983fa8a6a7571d04f3 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:30:28 +0100 Subject: [PATCH 09/15] feat(audit): register audit command in CLI and add package.json --- package.json | 25 +++++++++++++++++++++++++ src/cli.js | 26 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 package.json create mode 100644 src/cli.js diff --git a/package.json b/package.json new file mode 100644 index 0000000..79d4801 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "stxforge", + "version": "1.0.0", + "description": "Smart contract scaffolding CLI for the Stacks ecosystem", + "main": "src/index.js", + "bin": { + "stxforge": "./src/cli.js" + }, + "scripts": { + "audit": "node src/cli.js audit", + "test": "vitest run", + "lint": "eslint src/**/*.js" + }, + "dependencies": { + "chalk": "^5.3.0", + "commander": "^11.1.0", + "fs-extra": "^11.2.0", + "ora": "^7.0.1" + }, + "devDependencies": { + "vitest": "^1.2.0" + }, + "keywords": ["stacks", "clarity", "blockchain", "cli", "audit", "security"], + "license": "MIT" +} diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..38aeb59 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +const { program } = require('commander'); +const chalk = require('chalk'); +const { audit } = require('./commands/audit'); + +program + .name('stxforge') + .description('Smart contract scaffolding CLI for the Stacks ecosystem') + .version('1.0.0'); + +// stxforge audit [--json] +program + .command('audit') + .description('Run Clarity security checklist against contracts/ directory') + .option('--json', 'Output results as JSON (machine-readable)') + .action(async (opts) => { + try { + await audit(opts); + } catch (err) { + console.error(chalk.red('Error:'), err.message); + process.exit(1); + } + }); + +program.parse(process.argv); From 1052e61278a3432c4b0d08cd3727577e29840846 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:36:08 +0100 Subject: [PATCH 10/15] test(audit): add clean and vulnerable Clarity fixture contracts for rule testing --- tests/fixtures/clean-token.clar | 55 +++++++++++++++++++++++++ tests/fixtures/vulnerable-contract.clar | 38 +++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 tests/fixtures/clean-token.clar create mode 100644 tests/fixtures/vulnerable-contract.clar diff --git a/tests/fixtures/clean-token.clar b/tests/fixtures/clean-token.clar new file mode 100644 index 0000000..d7b28f7 --- /dev/null +++ b/tests/fixtures/clean-token.clar @@ -0,0 +1,55 @@ +;; CleanToken — a well-written SIP-010 token (no audit findings expected) + +(impl-trait 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.sip-010-trait) + +(define-fungible-token cleantoken) + +(define-constant CONTRACT-OWNER tx-sender) +(define-constant ERR-NOT-OWNER (err u100)) +(define-constant ERR-NOT-SENDER (err u101)) +(define-constant ERR-ZERO-AMOUNT (err u103)) +(define-constant ERR-SAME-ADDR (err u104)) + +(define-data-var token-name (string-ascii 32) "CleanToken") +(define-data-var token-symbol (string-ascii 10) "CLN") +(define-data-var token-decimals uint u6) +(define-data-var token-uri (optional (string-utf8 256)) none) + +;; Returns the token name +(define-read-only (get-name) (ok (var-get token-name))) + +;; Returns the token symbol +(define-read-only (get-symbol) (ok (var-get token-symbol))) + +;; Returns the number of decimals +(define-read-only (get-decimals) (ok (var-get token-decimals))) + +;; Returns balance for the given account +(define-read-only (get-balance (account principal)) + (ok (ft-get-balance cleantoken account))) + +;; Returns the current total supply +(define-read-only (get-total-supply) (ok (ft-get-supply cleantoken))) + +;; Returns the optional token URI +(define-read-only (get-token-uri) (ok (var-get token-uri))) + +;; Transfers tokens from sender to recipient +(define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34)))) + (begin + (asserts! (is-eq tx-sender sender) ERR-NOT-SENDER) + (asserts! (> amount u0) ERR-ZERO-AMOUNT) + (asserts! (not (is-eq sender recipient)) ERR-SAME-ADDR) + (try! (ft-transfer? cleantoken amount sender recipient)) + (match memo m (print m) 0x) + (ok true))) + +;; Updates the token metadata URI (owner only) +(define-public (set-token-uri (new-uri (optional (string-utf8 256)))) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-OWNER) + (var-set token-uri new-uri) + (ok true))) + +(begin + (try! (ft-mint? cleantoken u1000000000000 CONTRACT-OWNER))) diff --git a/tests/fixtures/vulnerable-contract.clar b/tests/fixtures/vulnerable-contract.clar new file mode 100644 index 0000000..49072cf --- /dev/null +++ b/tests/fixtures/vulnerable-contract.clar @@ -0,0 +1,38 @@ +;; VulnerableContract — intentionally bad contract for audit testing +;; This contract deliberately violates all 6 STX rules. + +(define-fungible-token badtoken) +(define-map balances principal uint) + +;; STX-001: map-set without asserts! +(define-public (unsafe-set (key principal) (val uint)) + (begin + (map-set balances key val) ;; no asserts! guard + (ok true))) + +;; STX-002: contract-call? without try! +(define-public (unsafe-call (target )) + (begin + (contract-call? .other-contract do-something) ;; not wrapped + (ok true))) + +;; STX-003: tx-sender used for auth +(define-public (auth-action) + (begin + (asserts! (is-eq tx-sender 'SP1ABC) (err u99)) + (var-set some-var u1) + (ok true))) + +;; STX-005: no doc comment above this function +(define-public (undocumented-function) + (begin + (asserts! (is-eq tx-sender 'SP1ABC) (err u99)) + (ok true))) + +;; STX-006: no principal check at all +(define-public (open-to-anyone) + (begin + (var-set some-var u99) + (ok true))) + +(define-data-var some-var uint u0) From dff203f58eb1a2156f976e2e875e7cc4bf3e3ed7 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:36:39 +0100 Subject: [PATCH 11/15] test(audit): add unit tests for all 6 audit rules with passing and failing fixtures --- tests/unit/audit-rules.test.js | 124 +++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/unit/audit-rules.test.js diff --git a/tests/unit/audit-rules.test.js b/tests/unit/audit-rules.test.js new file mode 100644 index 0000000..d0395f6 --- /dev/null +++ b/tests/unit/audit-rules.test.js @@ -0,0 +1,124 @@ +const { describe, it, expect } = require('vitest'); +const stx001 = require('../../src/audit/rules/stx-001-missing-asserts'); +const stx002 = require('../../src/audit/rules/stx-002-unchecked-contract-call'); +const stx003 = require('../../src/audit/rules/stx-003-tx-sender-misuse'); +const stx004 = require('../../src/audit/rules/stx-004-unbounded-iteration'); +const stx005 = require('../../src/audit/rules/stx-005-missing-docs'); +const stx006 = require('../../src/audit/rules/stx-006-no-principal-check'); + +// Helper +const lines = (src) => src.split('\n'); + +// ── STX-001 ──────────────────────────────────────────────────────────── +describe('STX-001: missing asserts!', () => { + it('flags map-set without asserts!', () => { + const src = `(define-public (bad) + (begin + (map-set my-map key val) + (ok true)))`; + expect(stx001.check(lines(src)).length).toBeGreaterThan(0); + }); + + it('does not flag map-set preceded by asserts!', () => { + const src = `(define-public (good) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-ONLY) + (map-set my-map key val) + (ok true)))`; + expect(stx001.check(lines(src)).length).toBe(0); + }); + + it('flags ft-transfer? without asserts!', () => { + const src = `(define-public (unsafe-transfer) + (begin + (ft-transfer? mytoken u100 sender recipient) + (ok true)))`; + expect(stx001.check(lines(src)).length).toBeGreaterThan(0); + }); +}); + +// ── STX-002 ──────────────────────────────────────────────────────────── +describe('STX-002: unchecked contract-call?', () => { + it('flags bare contract-call?', () => { + const src = `(contract-call? .other do-thing)`; + expect(stx002.check(lines(src)).length).toBeGreaterThan(0); + }); + + it('does not flag try!-wrapped contract-call?', () => { + const src = `(try! (contract-call? .other do-thing))`; + expect(stx002.check(lines(src)).length).toBe(0); + }); + + it('does not flag unwrap!-wrapped contract-call?', () => { + const src = `(unwrap! (contract-call? .other do-thing) ERR-FAIL)`; + expect(stx002.check(lines(src)).length).toBe(0); + }); + + it('ignores commented lines', () => { + const src = `;; (contract-call? .other do-thing)`; + expect(stx002.check(lines(src)).length).toBe(0); + }); +}); + +// ── STX-003 ──────────────────────────────────────────────────────────── +describe('STX-003: tx-sender misuse', () => { + it('flags is-eq tx-sender authorization check', () => { + const src = `(asserts! (is-eq tx-sender CONTRACT-OWNER) ERR)`; + expect(stx003.check(lines(src)).length).toBeGreaterThan(0); + }); + + it('ignores read-only functions using tx-sender', () => { + // STX-003 is about all tx-sender auth checks — it is a warning to review + const src = `(define-read-only (get-owner) tx-sender)`; + expect(stx003.check(lines(src)).length).toBe(0); + }); +}); + +// ── STX-004 ──────────────────────────────────────────────────────────── +describe('STX-004: unbounded iteration', () => { + it('flags fold inside a public function with list parameter', () => { + const src = `(define-public (process (items (list 100 uint))) + (begin + (asserts! true (err u1)) + (ok (fold + items u0))))`; + expect(stx004.check(lines(src)).length).toBeGreaterThan(0); + }); +}); + +// ── STX-005 ──────────────────────────────────────────────────────────── +describe('STX-005: missing doc comments', () => { + it('flags public function without preceding comment', () => { + const src = ` +(define-public (undocumented) + (ok true))`; + expect(stx005.check(lines(src)).length).toBeGreaterThan(0); + }); + + it('does not flag public function with preceding comment', () => { + const src = ` +;; Transfers tokens to recipient +(define-public (transfer (amount uint)) + (ok true))`; + expect(stx005.check(lines(src)).length).toBe(0); + }); +}); + +// ── STX-006 ──────────────────────────────────────────────────────────── +describe('STX-006: no principal check', () => { + it('flags public function with no principal validation', () => { + const src = `(define-public (open-fn) + (begin + (var-set my-var u1) + (ok true)))`; + expect(stx006.check(lines(src)).length).toBeGreaterThan(0); + }); + + it('does not flag function with CONTRACT-OWNER check', () => { + const src = `(define-public (protected-fn) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR) + (var-set my-var u1) + (ok true)))`; + expect(stx006.check(lines(src)).length).toBe(0); + }); +}); From bca76368d0dbc78f5a28b7aec5f3c8bcb90028f8 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:36:56 +0100 Subject: [PATCH 12/15] test(audit): add scanner and summarize unit tests using fixture contracts --- tests/unit/scanner.test.js | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/unit/scanner.test.js diff --git a/tests/unit/scanner.test.js b/tests/unit/scanner.test.js new file mode 100644 index 0000000..2c4e1ae --- /dev/null +++ b/tests/unit/scanner.test.js @@ -0,0 +1,50 @@ +const { describe, it, expect } = require('vitest'); +const path = require('path'); +const { scanContracts, summarize } = require('../../src/audit/scanner'); + +const FIXTURES_DIR = path.join(__dirname, '../fixtures'); + +describe('scanContracts', () => { + it('returns an empty array for a non-existent directory', async () => { + const results = await scanContracts('/nonexistent/path'); + expect(results).toEqual([]); + }); + + it('scans fixture contracts and returns results per file', async () => { + const results = await scanContracts(FIXTURES_DIR); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0]).toHaveProperty('file'); + expect(results[0]).toHaveProperty('findings'); + }); + + it('finds issues in the vulnerable fixture contract', async () => { + const results = await scanContracts(FIXTURES_DIR); + const vulnerable = results.find((r) => r.file.includes('vulnerable')); + expect(vulnerable).toBeDefined(); + expect(vulnerable.findings.length).toBeGreaterThan(0); + }); +}); + +describe('summarize', () => { + it('counts critical, warning, and info correctly', () => { + const results = [ + { + file: 'a.clar', + findings: [ + { severity: 'critical', ruleId: 'STX-001', line: 1, message: '' }, + { severity: 'warning', ruleId: 'STX-003', line: 2, message: '' }, + { severity: 'info', ruleId: 'STX-005', line: 3, message: '' }, + ], + }, + ]; + const summary = summarize(results); + expect(summary.critical).toBe(1); + expect(summary.warning).toBe(1); + expect(summary.info).toBe(1); + }); + + it('returns zeros for empty results', () => { + const summary = summarize([]); + expect(summary).toEqual({ critical: 0, warning: 0, info: 0 }); + }); +}); From 5547c6195c0cf9d7ab07201b09496b8da4d4a975 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:37:06 +0100 Subject: [PATCH 13/15] refactor(audit): add audit module barrel export --- src/audit/index.js | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/audit/index.js diff --git a/src/audit/index.js b/src/audit/index.js new file mode 100644 index 0000000..f850b31 --- /dev/null +++ b/src/audit/index.js @@ -0,0 +1,5 @@ +const { scanContracts, summarize } = require('./scanner'); +const { printReport, printJsonReport } = require('./reporter'); +const { ALL_RULES } = require('./rules'); + +module.exports = { scanContracts, summarize, printReport, printJsonReport, ALL_RULES }; From fa3ec95d29c74f1b8a0d20bdd2138a5a390e74b5 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:37:19 +0100 Subject: [PATCH 14/15] ci: add GitHub Actions workflow for automated Clarity audit on contract changes --- .github/workflows/audit-ci.yml | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/audit-ci.yml diff --git a/.github/workflows/audit-ci.yml b/.github/workflows/audit-ci.yml new file mode 100644 index 0000000..be70708 --- /dev/null +++ b/.github/workflows/audit-ci.yml @@ -0,0 +1,44 @@ +name: Clarity Audit + +on: + push: + paths: + - 'contracts/**/*.clar' + pull_request: + paths: + - 'contracts/**/*.clar' + +jobs: + audit: + runs-on: ubuntu-latest + name: Run stxforge audit + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run Clarity security audit + run: npm run audit -- --json > audit-report.json || true + + - name: Upload audit report + uses: actions/upload-artifact@v4 + with: + name: audit-report + path: audit-report.json + + - name: Fail on critical findings + run: node -e " + const r = require('./audit-report.json'); + if (r.summary.critical > 0) { + console.error('Critical audit findings detected!'); + process.exit(1); + } + console.log('Audit passed:', r.summary); + " From f1ffae678c29c100a6b07037d4b49000116a960f Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Sun, 22 Feb 2026 22:37:39 +0100 Subject: [PATCH 15/15] docs: add full audit command documentation with rules, exit codes, and JSON schema --- docs/audit-command.md | 78 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/audit-command.md diff --git a/docs/audit-command.md b/docs/audit-command.md new file mode 100644 index 0000000..580cd4e --- /dev/null +++ b/docs/audit-command.md @@ -0,0 +1,78 @@ +# stxforge audit + +Run a static security analysis against all Clarity contracts in your project. + +## Usage + +```bash +stxforge audit # human-readable output +stxforge audit --json # machine-readable JSON output +``` + +## Rules + +| Rule ID | Severity | Description | +|---------|----------|-------------| +| STX-001 | Critical | Missing `asserts!` before state-changing operations (`map-set`, `ft-transfer?`, etc.) | +| STX-002 | Critical | Unchecked `contract-call?` return value — wrap with `try!`, `unwrap!`, or `match` | +| STX-003 | Warning | `tx-sender` used for authorization — consider `contract-caller` in composable contexts | +| STX-004 | Warning | `fold`/`map` over a list parameter without a bounded size constraint | +| STX-005 | Info | Public function missing a `;;` doc comment | +| STX-006 | Warning | Public function with no principal validation (callable by anyone) | + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | No critical findings | +| `1` | One or more critical findings (STX-001 or STX-002) | + +This makes `stxforge audit` safe to use in CI pipelines: + +```yaml +- run: stxforge audit --json > audit.json +- run: node -e "if(require('./audit.json').summary.critical>0) process.exit(1)" +``` + +## JSON Output Format + +```json +{ + "summary": { + "critical": 2, + "warning": 1, + "info": 0 + }, + "contracts": [ + { + "file": "contracts/dao.clar", + "findings": [ + { + "ruleId": "STX-001", + "severity": "critical", + "line": 87, + "message": "State-changing operation without a preceding asserts! guard: `map-set` found without preceding `asserts!`" + } + ] + } + ] +} +``` + +## Example Terminal Output + +``` +✦ stxforge audit — Clarity Security Checklist + +Auditing 2 contracts... + +contracts/token.clar + ✓ No issues found + +contracts/dao.clar + CRIT [line 87] STX-001 State-changing operation without asserts!: map-set + CRIT [line 103] STX-002 contract-call? return value is not checked + WARN [line 42] STX-003 tx-sender used for authorization + +Audit complete: 2 critical, 1 warning, 0 info +```