Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
562df87
feat(audit): add STX-001 rule — missing asserts! before state-changin…
thewealthyplace Feb 22, 2026
8b49124
feat(audit): add STX-002 rule — unchecked contract-call? return values
thewealthyplace Feb 22, 2026
4083ab1
feat(audit): add STX-003 tx-sender misuse and STX-004 unbounded itera…
thewealthyplace Feb 22, 2026
39dd5f5
feat(audit): add STX-005 missing docs and STX-006 no principal check …
thewealthyplace Feb 22, 2026
ca4212e
feat(audit): register all 6 rules in rules index
thewealthyplace Feb 22, 2026
b6e10cd
feat(audit): implement contract scanner that applies all rules and su…
thewealthyplace Feb 22, 2026
008c28b
feat(audit): implement terminal and JSON reporters for audit findings
thewealthyplace Feb 22, 2026
794eff2
feat(audit): implement audit command handler with CI exit code support
thewealthyplace Feb 22, 2026
aba42d1
feat(audit): register audit command in CLI and add package.json
thewealthyplace Feb 22, 2026
1052e61
test(audit): add clean and vulnerable Clarity fixture contracts for r…
thewealthyplace Feb 22, 2026
dff203f
test(audit): add unit tests for all 6 audit rules with passing and fa…
thewealthyplace Feb 22, 2026
bca7636
test(audit): add scanner and summarize unit tests using fixture contr…
thewealthyplace Feb 22, 2026
5547c61
refactor(audit): add audit module barrel export
thewealthyplace Feb 22, 2026
fa3ec95
ci: add GitHub Actions workflow for automated Clarity audit on contra…
thewealthyplace Feb 22, 2026
f1ffae6
docs: add full audit command documentation with rules, exit codes, an…
thewealthyplace Feb 22, 2026
0f56f38
Merge branch 'main' into fix/issue-2-audit-command
thewealthyplace Feb 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/audit-ci.yml
Original file line number Diff line number Diff line change
@@ -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);
"
78 changes: 78 additions & 0 deletions docs/audit-command.md
Original file line number Diff line number Diff line change
@@ -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
```
5 changes: 5 additions & 0 deletions src/audit/index.js
Original file line number Diff line number Diff line change
@@ -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 };
52 changes: 52 additions & 0 deletions src/audit/reporter.js
Original file line number Diff line number Diff line change
@@ -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 };
10 changes: 10 additions & 0 deletions src/audit/rules/index.js
Original file line number Diff line number Diff line change
@@ -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 };
65 changes: 65 additions & 0 deletions src/audit/rules/stx-001-missing-asserts.js
Original file line number Diff line number Diff line change
@@ -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 };
41 changes: 41 additions & 0 deletions src/audit/rules/stx-002-unchecked-contract-call.js
Original file line number Diff line number Diff line change
@@ -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 };
42 changes: 42 additions & 0 deletions src/audit/rules/stx-003-tx-sender-misuse.js
Original file line number Diff line number Diff line change
@@ -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 };
52 changes: 52 additions & 0 deletions src/audit/rules/stx-004-unbounded-iteration.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading