
Статический анализатор без зависимостей для поиска ошибок корректности zk-схем в o1js/Mina zkApps и схемах Noir
Пакет сообщества:
o1js-scanуказан в официальном каталоге o1js Community Packages.
Последняя версия: 0.20.0 — анализатор теперь читает контракты, которые
extends TokenContract. До этого релиза проверка контрактов сопоставлялась только сSmartContract, поэтому каждый взаимозаменяемый токен, коллекция NFT и пул AMM в экосистеме сканировались как "no findings". Если вы сканировали контракт токена до 0.20.0, просканируйте его снова. См. .
Быстрый статический анализатор без зависимостей для ошибок корректности zk-схем в:
.ts / .js) — схемы Kimchi из тел @method.nr) — Rust-подобный ZK DSL от Aztec (включая шаблоны в стиле aztec-nr)Критичные для безопасности ошибки обычно находятся не в системе доказательства — они в
собственных ограничениях приложения: свидетели, которые контролирует доказывающий, но схема
их никогда не связывает. o1js-scan — это сканер недостаточно ограниченных сигналов для
родственников Circom в экосистемах Mina и Noir.```bash
pip install o1js-scan
o1js-scan path/to/zkapp # o1js + Noir (auto) noir-scan path/to/circuits # same binary — Noir-friendly alias noir-scan . --lang noir --fail-on high --sarif noir.sarif
### Пример
Дан сейф, в котором сумма `withdraw` является управляемым доказывающим свидетелем, который
никогда не привязывается к состоянию on-chain:```console
$ o1js-scan examples/vulnerable_vault.ts --include-examples
LOW O1JS_UNCONSTRAINED_RECIPIENT vulnerable_vault.ts:23 fn=withdraw Recipient `to` is prover-chosen in `withdraw`
HIGH O1JS_UNCONSTRAINED_WITNESS vulnerable_vault.ts:23 fn=withdraw Unconstrained witness `amount` flows to send_amount in `withdraw`
o1js-scan: 2 finding(s) [1 high, 1 low] in 1 of 1 file(s) — fails (--fail-on high)
$ echo $?
1
--include-examples нужен здесь только потому, что демонстрационный файл находится в
examples/, который классификатор путей по умолчанию понижает в приоритете, чтобы
собственный пример кода репозитория не мог сломать его сборку. Тот же контракт в вашем src/ сообщает
HIGH без флага.
Находка HIGH — это ошибка, позволяющая опустошить контракт. Исправленный контракт
(examples/safe_vault.ts) отбрасывает её и завершается с 0, сохраняя только информационную
LOW о получателе, выбранном доказывающим:```console
$ o1js-scan examples/safe_vault.ts --include-examples
LOW O1JS_UNCONSTRAINED_RECIPIENT safe_vault.ts:23 fn=withdraw Recipient to is prover-chosen in withdraw
o1js-scan: 1 finding(s) [1 low] in 1 of 1 file(s) — passes (--fail-on high)
$ echo $?
0
См. [`examples/`](https://github.com/auditinfra-io/o1js-scan/blob/main/examples) для уязвимых/исправленных пар o1js и Noir.
## Содержание
- [Установка](#install)
- [Использование](#usage) · [Подавление находки](#suppressing-a-reviewed-finding)
- [GitHub Action](#github-action)
- [Что он обнаруживает — o1js](#what-it-detects-o1js) · [Noir](#what-it-detects-noir)
- [Известные ограничения](#known-limitations) · [Где этот инструмент останавливается](#where-this-tool-stops)
- [Конфиденциальность и приватный код](#privacy-and-private-code)
- [Постквантовая проверка](#post-quantum-review)
- [Совместимость](#compatibility) · [Как это работает](#how-it-works)
- [Участие в разработке](#roadmap--contributing)
## Установка```bash
pip install o1js-scan
Для изолированной глобальной установки CLI используйте pipx:```bash
pipx install o1js-scan
Для репозиториев приложений на Node/npm, использующих Noir, Aztec или o1js, установите npm-обёртку:```bash
npm install -D o1js-scan
npx noir-scan . --lang noir --fail-on high
Пакет npm — это тонкая обёртка вокруг того же Python-анализатора, и для его работы требуется Python 3.8+ в PATH (python3 или python). Задайте O1JS_SCAN_PYTHON, чтобы выбрать конкретный интерпретатор.
Или из исходного кода:```bash git clone https://github.com/auditinfra-io/o1js-scan cd o1js-scan pip install -e .
Сторонних зависимостей Python нет. Требуется Python 3.8+. Консольный скрипт `noir-scan`
устанавливается вместе с `o1js-scan` (та же точка входа), в том числе через обёртку
npm.
## Использование```bash
# scan a directory (recursively; skips node_modules, target/, .git, …)
o1js-scan path/to/project
# Noir-only / o1js-only
noir-scan circuits --lang noir
o1js-scan src --lang o1js
# scan a single file
o1js-scan src/MyContract.ts
noir-scan src/main.nr
# machine-readable output for CI
o1js-scan src --json
# SARIF 2.1.0 for GitHub code scanning (writes o1js-scan.sarif by default)
o1js-scan src --sarif
noir-scan . --lang noir --sarif noir.sarif
# choose which severity fails CI (critical|high|medium|low|none; default high)
o1js-scan src --fail-on medium
# progressive/power-user gate (equivalent to --fail-on medium)
o1js-scan src --strict
# test code is excluded by default (both backends); opt back in
o1js-scan src --include-tests
# example code is downgraded to LOW by default; keep original severity
o1js-scan src --include-examples
o1js-scan --version
Код выхода равен 1, когда присутствует находка уровня --fail-on (по умолчанию
high) или выше, и 0 в противном случае — так что его можно напрямую встроить в CI.
При значении по умолчанию находка уровня low/medium (включая информационное правило
о получателе ниже) не приводит к сбою сборки; используйте --fail-on none, чтобы
только сообщать, или --strict (сокращение для --fail-on medium), чтобы задать более
строгий порог, по-прежнему считая находки низкой серьёзности рекомендательными. Эти две
опции взаимоисключающие, поэтому конфигурация CI не может быть неоднозначной.
Отсутствующий путь сканирования завершается с кодом 2 и ошибкой в stderr, так что
опечатка не может незаметно пройти CI как чистое выполнение. Каждый запуск выводит
однострочную сводку (количество по уровням серьёзности и вердикт гейта) в stderr.
Тестовый код исключается по умолчанию — в обоих бэкендах. Тесты намеренно создают недопустимые значения и плохие транзакции, чтобы доказать, что проверки их отклоняют, поэтому находка там — это суть теста, а не ошибка схемы. Файл считается тестовым кодом, когда:
*.test.ts / *.spec.ts (а также вариантам .js/.jsx/.tsx/.mjs
/.cjs), или *_test.nr / test_*.nr;test/, tests/, __tests__/, spec/ или __mocks__/;#[test] / #[test(...)],
или находится внутри блока mod test { … } / mod tests { … } —
с областью действия блока, поэтому тестовый модуль в конце production-файла не
заглушает остальную его часть.Передайте --include-tests, чтобы сообщать о них.
Пример кода понижается в серьёзности, а не отбрасывается. Находка в каталоге
examples/ или example/, либо в файле с именем *.eg.ts (а также .nr и другие
расширения JS/TS), понижается до LOW с примечанием — всё ещё сообщается, но больше
не может привести к сбою сборки. Пример кода намеренно упрощён, и помечать собственные
примеры фреймворка как уязвимости — это шум; но он копируется в production гораздо
чаще, чем тестовый код, поэтому его понижают, а не скрывают. Передайте
--include-examples, чтобы сохранить исходную серьёзность.
Всякий раз, когда применяется любая из этих политик, запуск выводит строку в stderr
с указанием этого — например, 6 file(s) skipped as test code, 1 finding(s) downgraded as examples —
так что тихое сканирование никогда не бывает молча тихим. Эти счётчики также появляются
в SARIF в разделе invocation.properties. Обратите внимание на компромисс: обнаружение
основано только на пути (без разбора describe(/it(), поэтому production-схема,
хранящаяся в tests/, будет пропущена — строка в stderr — это то, как вы это заметите.
Каталоги, пропускаемые при обходе дерева: node_modules, target (nargo),
.git, dist, build, __pycache__, .venv, venv.
Заглушите находку, которую вы уже разобрали, не ослабляя гейт, с помощью встроенного комментария на — или на строке выше — отмеченной строке:```ts this.send({ to, amount }); // o1js-scan-disable-line O1JS_UNCONSTRAINED_WITNESS
// o1js-scan-disable-next-line this.send({ to, amount });
| `--no-color` | Отключить цветной вывод |
| `--debug` | Включить режим отладки |
| `--verbose` | Включить подробный вывод |
| `--silent` | Подавить весь вывод |
| `--json` | Выводить результаты в формате JSON |
| `--csv` | Выводить результаты в формате CSV |
| `--html` | Выводить результаты в формате HTML |
| `--markdown` | Выводить результаты в формате Markdown |
| `--output <file>` | Записать вывод в файл |
| `--config <file>` | Использовать указанный файл конфигурации |
| `--threads <n>` | Количество потоков (по умолчанию: 10) |
| `--timeout <n>` | Тайм-аут в секундах (по умолчанию: 30) |
| `--retries <n>` | Количество повторных попыток (по умолчанию: 3) |
| `--proxy <url>` | Использовать прокси |
| `--user-agent <string>` | Установить User-Agent |
| `--cookie <string>` | Установить Cookie |
| `--header <string>` | Установить пользовательский заголовок |
| `--rate-limit <n>` | Ограничить запросы в секунду |
| `--random-agent` | Использовать случайный User-Agent |
| `--follow-redirects` | Следовать перенаправлениям |
| `--no-verify-ssl` | Отключить проверку SSL |
| `--insecure` | Разрешить небезопасные соединения |
| `--force` | Принудительное выполнение |
| `--yes` | Автоматически отвечать «да» на все запросы |
| `--no-interactive` | Отключить интерактивный режим |
| `--version` | Показать версию |
| `--help` | Показать справку |```nr
let inv = unsafe { hint(x) }; // o1js-scan-disable-line NOIR_UNCONSTRAINED_WITNESS
Укажите один или несколько идентификаторов правил, чтобы подавить только их; директива без идентификаторов подавляет все правила в целевой строке.
Как библиотека:```python from o1js_scan import analyze_file, analyze_project
for path, finding in analyze_project("src", lang="auto"): print(path, finding.rule_id, finding.severity.value, finding.title)
## GitHub Action
Добавьте сканер в CI в несколько строк. Результаты отображаются как аннотации в diff'е PR
и как оповещения во вкладке **Security → Code scanning** репозитория.```yaml
# .github/workflows/o1js-scan.yml
name: o1js-scan
on: [push, pull_request]
permissions:
contents: read
security-events: write # required to upload SARIF to code scanning
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: auditinfra-io/[email protected]
with:
path: src # optional, defaults to the repo root
lang: auto # auto | o1js | noir
# version: 0.20.0 # optional, pin the scanner version
# fail-on: high # optional, fail the job on high/critical
Рекомендуется для проектов Noir, которым нужны оповещения сканирования кода и порог высокой критичности:```yaml
Или без Action:```bash
pip install o1js-scan
noir-scan . --lang noir --fail-on high --sarif noir.sarif
Входные данные: `path` (по умолчанию `.`), `lang` (`auto`|`o1js`|`noir`, по умолчанию `auto`),
`version` (версия PyPI для установки, по умолчанию последняя), `upload-sarif` (по умолчанию
`true`), `fail-on` (`critical`|`high`|`medium`|`low`|`none`, по умолчанию `none`),
`fail-on-findings` (устарело, по умолчанию `false`), `include-tests` (по умолчанию
`false`), `include-examples` (по умолчанию `false`). Выходные данные: `sarif-file`. Для загрузки SARIF
требуются `security-events: write` и включённое сканирование кода.
Отчёт и гейт строятся из одного массива аргументов, поэтому `include-tests`
и `include-examples` применяются к обоим — SARIF, который вы читаете, и код возврата, по которому вы
принимаете решение, всегда описывают один и тот же набор исходников. Проход отчётности выполняется с
`--fail-on none`, поэтому находки никогда не блокируют загрузку SARIF, но операционный
сбой (несуществующий путь, ошибка использования CLI) всё равно приводит к сбою шага,
а не сообщается как чистое сканирование.
`fail-on-findings: true` сохранён для совместимости и отображается на `fail-on: high`,
когда `fail-on` оставлен на `none`; он выдаёт предупреждение об устаревании. Предпочитайте
`fail-on`, который может ограничивать на любом уровне серьёзности.
## Что он обнаруживает (o1js)
### Поддерживаемые правила кратко
<!-- BEGIN GENERATED RULE SUMMARY -->
| Backend | Rules | High-capable | Medium-capable | Low-capable |
|---------|------:|-------------:|---------------:|------------:|
| o1js | 18 | 11 | 12 | 2 |
| Noir | 11 | 4 | 9 | 1 |
| **Total** | **29** | **15** | **21** | **3** |
<!-- END GENERATED RULE SUMMARY -->
Счётчики — это уникальные ID правил, поддерживаемых каждым бэкендом. Правило, которое назначает
серьёзность в зависимости от контекста (например, high для перевода стоимости и
medium для записи состояния), появляется более чем в одной колонке серьёзности, поэтому колонки
серьёзности намеренно не суммируются в общее число правил. В настоящее время нет правил
уровня critical или info. Полные описания и
защиты от ложных срабатываний приведены ниже.
<!-- BEGIN GENERATED O1JS RULE TABLE -->
| Rule | Severity | What it means |
|------|----------|---------------|
| `O1JS_MISSING_STATE_PRECONDITION` | high | `this.x.get()` читается без соответствующего `requireEquals(...)` / `getAndRequireEquals()`. Простой `get()` не добавляет **никакого** предусловия аккаунта, поэтому доказательство не привязывает `x` к его on-chain значению — доказывающий может подставить любое значение. |
| `O1JS_UNCONSTRAINED_WITNESS` | high / medium | Аргумент `@method` (приватный свидетель, контролируемый доказывающим) попадает в **сумму** отправки (`this.send(...)` или `AccountUpdate.create*(...).send(...)` в том же методе) или в `.set(...)` состояния и **никогда** не проверяется утверждением. Прямой аналог недостаточно ограниченного сигнала Circom. High, когда он достигает перевода стоимости. |
| `O1JS_UNCONSTRAINED_PROVABLE_WITNESS` | high / medium / low | Локальная переменная `Provable.witness(...)` попадает в эффект отправки/состояния **без** внутрисхемного утверждения. Callback свидетеля выполняется *вне* схемы (это лишь подсказка доказывающему), поэтому результат — свежее значение, контролируемое доказывающим — другой источник свидетелей помимо аргументов `@method`. Его нужно заново вывести и утвердить (`x.assertEquals(<recomputed>)`) или привязать к состоянию. High для суммы отправки (`this.send(...)` или `AccountUpdate.create*` в том же методе). |
| `O1JS_UNCONSTRAINED_RECIPIENT` | low | Аргумент `@method` используется **только** как получатель `to:` при отправке. Обычно это намеренно (пользователь указывает собственный адрес вывода) и носит информационный характер — это важно только если получатель должен быть фиксированной казной или адресом, записанным в состоянии. **Не** приводит к срабатыванию гейта кода возврата CI. |
| `O1JS_WITNESS_NOT_BOUND_TO_STATE` | medium | Свидетель ограничен лишь *тривиально* (например, `> 0` или сравнением с константой) перед эффектом — никогда не привязан к on-chain состоянию. Убедитесь, что внецепочечная оркестрация делает это безопасным, иначе баланс можно опустошить вплоть до его текущего значения. |
| `O1JS_STALE_MERKLE_ROOT` | high | Метод пересчитывает корень Меркла из свидетеля, предоставленного доказывающим (`computeRootAndKey` / `calculateRoot`), но не привязывает **ни один** из пересчитанных корней к текущему on-chain корню. Без `this.root.requireEquals(...)` / `assertEquals` относительно живого корня доказывающий может передать свидетеля для сфабрикованного или устаревшего дерева — подделав членство или воспроизведя старое состояние. Привязка может находиться в недекорированном вспомогательном методе того же класса (`this.verifyX(witness)`); распространение через вспомогательные методы это покрывает. |
| `O1JS_UNVERIFIED_PROOF` | high | Параметр `@method` с типом `Proof<...>` / `SelfProof<...>` / `DynamicProof<...>` никогда не проходит `.verify()` до использования его публичных полей. Передача Proof не верифицирует его — без явной проверки доказывающий может предоставить произвольный объект доказательства, и любое использование его `publicOutput` не ограничено. Также срабатывает, когда `.verifyIf(flag)` управляется неограниченным аргументом `@method` и читаются публичные поля доказательства, поскольку доказывающий может сделать условие ложным. |
| `O1JS_UNASSERTED_BOOL` | high / medium | Предикат o1js (`equals` / `lessThanOrEqual` / …) возвращает `Bool` и не добавляет **никакого** ограничения, если результат не утверждён или не использован. HIGH, когда вызов — это просто отброшенный оператор; MEDIUM, когда он присвоен локальной переменной, на которую больше нигде не ссылаются. |
| `O1JS_UNCONSTRAINED_SENDER` | high / medium | `this.sender.getUnconstrained()` возвращает отправителя транзакции без его доказательства. HIGH, когда это значение (или локальная переменная из него) попадает в assert / `.set` состояния / `send` (пустая проверка); MEDIUM в остальных случаях. Предпочитайте `this.sender.getAndRequireSignature()` или развёрнутую идиому `AccountUpdate.createSigned(sender)`. **Молчит, когда** (1) тот же `@method` также вызывает `this.sender.getAndRequireSignature()` где-либо (требование подписи действует в пределах метода), или (2) значение свидетеля-отправителя является аргументом `AccountUpdate.createSigned(...)` / `AccountUpdate.create(...).requireSignature()` на том же ключе (требуется идентичность аргумента — `createSigned` на другом ключе не подавляет). |
| `MissingRangeCheck` | high | Сырое `Field` (а не проверенное на диапазон `UInt64`/`UInt32`) используется как сумма перевода. `Field` — это элемент по модулю p и не ограничен по диапазону. |
| `O1JS_WEAK_PERMISSIONS` | high / medium | `editState` / `send` установлены в `proofOrSignature()` или `none()`, позволяя ключу аккаунта zkApp обойти схему путём подписи. Также отмечает `setVerificationKey` / `setPermissions`, оставленные на `signature` / `proofOrSignature` / `none` (документированные Mina «training wheels» для обновления); HIGH в сочетании со слабыми `editState`/`send` в том же `permissions.set`. |
| `O1JS_LOGIC_OUTSIDE_PROOF` | high | Логика безопасности (assert / approve / send / `.set` состояния) внутри `Provable.asProver(...)` или callback `Provable.witness*`. Эти callback выполняются *вне* схемы — злонамеренный доказывающий может удалить их и всё равно получить верифицирующее доказательство. |
| `O1JS_APPROVE_WITHOUT_BINDING` | medium | `@method` вызывает `approve` / `approveAccountUpdate` / `approveBase` без чтения `balanceChange` / `publicKey` и без `assertCanMint` / `assertCanBurn` / проверки сохранения через `forEachUpdate` — архетип Mina FlawedTokenContract. |
| `O1JS_VACUOUS_ASSERT` | high / medium | Утверждение, которое выполняется по построению: `x.assertEquals(x)`, `x.equals(x).assertTrue()` или `Bool(true).assertTrue()`. HIGH для самосравнений (почти всегда опечатка); MEDIUM для утверждений константного Bool. |
| `O1JS_CONDITIONAL_ASSERT` | medium | Утверждение внутри `if <flag> { ... }`, где `<flag>` — это контролируемый доказывающим `Bool` из `@method` (или локальная переменная из `.toBoolean()`). Условный оператор JS не ограничивает схему так, как это делает `Provable.if`. Встроенные сравнения не сообщаются ради точности. |
| `O1JS_GUARDED_INVERSE` | medium | `.div()` / `.inv()` / `.sqrt()` внутри ветви `Provable.if`, защищённой условием на само значение, на котором она терпит неудачу. Обе ветви вычисляются в схеме, и эти вызовы безусловно утверждают, что обратное значение или корень существует, поэтому защита не пропускает утверждение — схема невыполнима ровно для того входа, для которого защита была написана, и метод никогда не может быть доказан для него. Сообщается Veridise как `V-O1J-VUL-060`. Сначала вычислите безопасный делитель (`Provable.if(isZero, Field(1), d)`) и выберите результат после. **Молчит, когда** защита ничего не говорит о делителе, поэтому несвязанный `Provable.if` вокруг безопасного деления не отмечается. |
| `O1JS_PRECONDITION_OVERWRITTEN` | medium | Два или более вызовов `requireEquals` / `requireBetween` / `requireNothing` на **одном и том же** свойстве в одном методе с различающимися аргументами. Предусловия *устанавливаются* на AccountUpdate, а не накапливаются, поэтому каждый вызов перезаписывает предыдущий и применяется только последний — в отличие от внутрисхемных утверждений, которые компонуются. `a.requireEquals(b)`, затем `a.requireEquals(c)` подразумевает `a === c`, а не `a === b`. Сообщается Veridise как `V-O1J-VUL-012`. **Молчит, когда** аргументы идентичны (идемпотентно, ничего не теряется), при `getAndRequireEquals()` (другой метод, поэтому повторные чтения состояния допустимы), и когда вызовы находятся во взаимоисключающих ветвях JS, которые разрешаются во время построения схемы. Это последнее исключение может скрыть реальную перезапись, охватывающую несвязанный `if`/`else`. |
| `O1JS_STATE_READ_AFTER_WRITE` | medium | Поле `@state` читается (`get()` / `getAndRequireEquals()`) после завершения `set(...)` на том же поле в том же методе. `set()` записывает изменение в AccountUpdate, но не записывает сквозь в `get()`, поэтому чтение всё ещё наблюдает значение до записи, и любая арифметика на его основе молча смещена на эту запись. Сообщается Veridise как `V-O1J-VUL-030`. Храните новое значение в локальной переменной вместо повторного чтения состояния. **Молчит, когда** чтение вложено в собственные аргументы записи (идиома read-modify-write `this.x.set(this.x.getAndRequireEquals().add(1))`, которая корректна), и когда запись и чтение находятся во взаимоисключающих ветвях JS. Ограничено одним методом — случай кэширования между методами, который также описывает Veridise, требует знания графа вызовов, которого у этого правила нет. |
<!-- END GENERATED O1JS RULE TABLE -->
### Защиты от ложных срабатываний (o1js)
Анализатор спроектирован так, чтобы молчать на корректном коде:
- **Методы, защищённые подписью, пропускаются.** `@method`, который вызывает
`this.requireSignature()` (или `getAndRequireSignature`, `AccountUpdate.createSigned`,
`Signature.verify`), защищён владельцем/администратором — его аргументы выбираются держателем
ключа, а не произвольным доказывающим — поэтому его свидетели не отмечаются. Это
эквивалент `onlyOwner` в o1js.
- **Свидетели, привязанные к состоянию, пропускаются.** Аргумент, утверждённый равным (или
ограниченный сравнением порядка относительно) значению, полученному из `getAndRequireEquals()`,
корректен и не будет сообщён. Это покрывает как прямую форму —
`amount.assertLessThanOrEqual(bal)` — так и цепочечную форму
`amount.lessThanOrEqual(bal).assertTrue()`. Привязка, находящаяся в
недекорированном вспомогательном методе того же класса (`this.verifyX(arg)`), также распознаётся,
в том числе через цепочку таких вспомогательных методов.
- **Верифицированные доказательства пропускаются.** Аргумент типа `Proof` / `SelfProof` / `DynamicProof` /
`*Proof`, для которого вызывается `.verify()`, ограничен
верифицированной схемой — находки о свидетелях на нём (и его `publicOutput` /
`publicInput`) подавляются. `.verifyIf(flag)` засчитывается только когда
условие не является неограниченным аргументом метода или само утверждено. То же
относится к канонической обёртке OffchainState
`this.offchainState.settle(proof)` (фреймворк верифицирует внутри `settle`).
Самодельный `.settle(proof)` **не** предполагается верифицирующим. Обратный случай (аргумент типа proof никогда не верифицирован
и не урегулирован через OffchainState) сообщается как `O1JS_UNVERIFIED_PROOF`.
- **Утверждённые / использованные Bool пропускаются.** Предикат, сцепленный с
`.assertTrue()` / `.assertFalse()`, вложенный в `Provable.if(...)` или
присвоенный локальной переменной, на которую позже ссылаются, не сообщается как
`O1JS_UNASSERTED_BOOL`.
- **Аутентифицированные отправители пропускаются.** `this.sender.getUnconstrained()`
не срабатывает, когда тот же `@method` также вызывает
`this.sender.getAndRequireSignature()`, или когда это значение свидетеля
передаётся в `AccountUpdate.createSigned(...)` / аутентифицируется через
`.requireSignature()` на AccountUpdate, построенном из него (требуется идентичность
аргумента).
- Комментарии и строковые литералы удаляются перед анализом, поэтому `assert`
внутри строки не может создать ложный результат.
## Что он обнаруживает (Noir)
Та же идея корректности — недостаточно ограниченные свидетели — применяется к
[Noir](https://noir-lang.org) схемам (`.nr`). Направьте сканер на файлы `.nr`
(или используйте `--lang noir`), и он проанализирует их с набором правил Noir.
Тот же лексический подход без зависимостей. Откалиброван по идиомам оракулов aztec-nr /
`unsafe` — см. [`docs/noir_calibration.md`](https://github.com/auditinfra-io/o1js-scan/blob/main/docs/noir_calibration.md).
<!-- BEGIN GENERATED NOIR RULE TABLE -->
| Rule | Severity | What it means |
|------|----------|---------------|
| `NOIR_UNCONSTRAINED_WITNESS` | high | Значение, полученное из блока `unsafe { ... }` — результат `unconstrained fn` (оракул / подсказка Brillig) — которое никогда не переограничивается через `assert` / `assert_eq` (или подтверждающий вспомогательный метод / проверку Меркла). Подсказка выполняется **вне** схемы. Аналог `O1JS_UNCONSTRAINED_PROVABLE_WITNESS`. |
| `NOIR_UNCONSTRAINED_INPUT` | medium | Приватный (свидетель) вход `fn main`, который не попадает **ни в один** `assert` / `assert_eq` и **не** является частью публичного вывода. Аналог `O1JS_UNCONSTRAINED_WITNESS`. |
| `NOIR_UNCONSTRAINED_PUBLIC_INPUT` | medium | **Публичный** вход `fn main`, который не достигает ни ограничения, ни вывода — схема никогда его не читает. *Двойник* правила о приватном свидетеле: верификатор предоставляет значение и верит, что утверждение о нём, тогда как схема его игнорирует (например, `merkle_root: pub Field`, который никогда не проверяется, поэтому членство фактически никогда не было доказано). MEDIUM, потому что намеренно неиспользуемый публичный вход также является законной идиомой для привязки доказательства к контексту (nonce / chain id / получатель), что лексически неотличимо — поэтому он не ограничивает CI при значении по умолчанию `--fail-on high`. |
| `NOIR_UNCHECKED_CAST` | medium | Контролируемое доказывающим значение, приведённое к узкому беззнаковому типу (`as u8`/`u16`/`u32`) **без** утверждения о диапазоне. Аналог o1js `MissingRangeCheck`. |
| `NOIR_UNCONSTRAINED_ARRAY_INDEX` | medium | Контролируемое доказывающим значение, используемое как индекс массива (`arr[i]`) **без** какой-либо проверки. Неявная проверка границ Noir устанавливает лишь то, что индекс *в пределах диапазона* — а не то, что он *правильный* — поэтому доказывающий остаётся свободным выбрать любой элемент и всё равно получить верифицирующее доказательство. Это баг свободы выбора селектора, стоящий за позициями путей Меркла, выбором нот и членством в allow-list. Подавляется, когда индекс ограничен по диапазону, закреплён равенством, ограничен перед приведением (`index.assert_max_bit_size::<8>(); let i = index as u32;`) или когда считанное значение само закреплено через `assert_eq`. |
| `NOIR_UNASSERTED_BOOL` | high / medium | Сравнение, чей результат `bool` **отброшен**. Аналог o1js `O1JS_UNASSERTED_BOOL`. |
| `NOIR_CONDITIONAL_ASSERT` | medium | `assert` внутри `if <flag> { ... }`, где `<flag>` — контролируемый доказывающим простой `bool` или локальная переменная, производная от контролируемых доказывающим значений. Ограничение внутри условия применяется только когда условие истинно, поэтому выбранная доказывающим ветвь может пропустить проверку. Встроенные сравнения (`if x != 0`) не трогаются ради точности; присвоение защиты локальной переменной (`let gate = x != 0; if gate`) сообщается, если сам `gate` не утверждён. |
| `NOIR_CONDITIONAL_CONSTRAIN` | medium | Вызов `constrain_*` / `confirm_*` / `verify_*` только под контролируемым доказывающим `if`, тогда как подсказка `unsafe` всё ещё достигает вывода. |
| `NOIR_UNUSED_CHECK_RESULT` | high / medium | Результат `check_*` / `confirm_*` / `verify_*` / `constrain_*` отброшен (простой вызов) или присвоен и никогда не утверждён — проверка не привязывает схему. |
| `NOIR_VACUOUS_CONSTRAINT` | high / medium | Ограничение, которое выполняется по построению: самосравнение (`assert(x == x)`, `assert_eq(x, x)`, `x >= x`) или константное условие (`assert(true)`). Оно не добавляет никакого ограничения, но строка *читается* как проверка — что делает её более опасной, чем отсутствующее ограничение, потому что ревью на ней останавливается. HIGH для самосравнения (почти всегда опечатка вместо реальной проверки: `assert(computed == expected)`, набранное как `assert(expected == expected)`); MEDIUM для константы, которая чаще является заполнителем. `x != x` **не** отмечается — это невыполнимо, баг живости, а не молчаливая дыра в корректности. |
| `NOIR_UNSAFE_MISSING_SAFETY` | low | Блок `unsafe { ... }` без соседнего комментария `// Safety:`. Информационно; не приводит к сбою CI при значении по умолчанию `--fail-on high`. |
<!-- END GENERATED NOIR RULE TABLE -->
### Защиты от ложных срабатываний (Noir)
- **Assert / переход через let / подтверждающие вспомогательные методы в том же файле** привязывают подсказки `unsafe`.
- **Имена на месте вызова** `constrain_*` / `confirm_*` / `verify_*` /
`check_(non_)membership*` / `public_data_storage_read` засчитывают аргументы (с обнаружением неиспользованного результата для отброшенных проверок).
- **Документированные намеренно неограниченные** (требуется соседний `// Safety:`):
`random()`, `avm::…` и отложенные формулировки kernel/rollup/discovery.
- **Кортежный `let` + утверждённые флаги** привязывают свидетелей Меркла, переданных в проверки членства.
Пример:```console
$ noir-scan examples/noir_unconstrained.nr --include-examples
HIGH NOIR_UNCONSTRAINED_WITNESS noir_unconstrained.nr:16 fn=main Unconstrained `unsafe` result `inv` in `main`
LOW NOIR_UNSAFE_MISSING_SAFETY noir_unconstrained.nr:16 fn= `unsafe` block without a `// Safety:` comment
noir-scan: 2 finding(s) [1 high, 1 low] in 1 of 1 file(s) — fails (--fail-on high)
$ noir-scan examples/noir_constrained.nr --include-examples
noir-scan: no findings in 1 o1js or Noir file(s) — passes (--fail-on high)
Как и в примере с o1js выше, --include-examples нужен только потому, что
эти демонстрационные файлы находятся в examples/.
Анализатор — это лексический фронтенд без зависимостей плюс облегчённый семантический слой, который выполняет отслеживание псевдонимов и межпроцедурное распространение через вспомогательные методы того же класса. Это не фронтенд компилятора TypeScript, не проверяющий типы модуль и не движок потоков данных для всей программы, и в этом сканере нет слоя SMT или формальных доказательств. Учитывайте эти слепые зоны при триаже — они известны и намеренны для этой архитектуры без зависимостей, а не являются ошибками:
Отслеживаются только простые псевдонимы. Отслеживание свидетелей следует за простыми
псевдонимами в пределах одного метода, такими как const q = qty, но не за производными выражениями или
деструктуризацией: ```ts
const q = qty; this.send({ to: dest, amount: q }); // followed
const q = qty.add(1); this.send({ to: dest, amount: q }); // not followed
const slot = this.root; slot.get(); // missing precondition missed
Кросс-методная привязка охватывает только цепочки помощников внутри одного класса. Недекорированный помощник того же класса, вызываемый как this.verifyX(arg), может привязать аргумент вызывающего, и начиная с 0.19.0 цепочки таких помощников (@method → помощник A → помощник B) прослеживаются до неподвижной точки. Шаг помощник→помощник отображает только голую ссылку на параметр, поэтому helperA(x.add(1)) не распространяется. Свободные и импортированные функции по-прежнему не прослеживаются, а псевдонимизация аргумента помощника через локальную переменную остаётся задокументированным ограничением.
Обнаружение неутверждённых Bool имеет форму инструкции. Уровень A помечает только голые выражения-инструкции, у которых самый внешний вызов — это Bool-предикат, и после него ничего не сцеплено. Предикаты, вложенные в Provable.if(...), или присвоенные и позже используемые, не помечаются. Сложные варианты использования Bool-локальной переменной в управляющем потоке всё ещё могут быть пропущены, если имя никогда не упоминается (режим отказа: пропуск, а не ложное срабатывание).
Гейтирование по сигнатуре — на уровне метода и на основе подстрок. _method_is_signature_gated рассматривает весь @method как гейтированный владельцем, если он содержит идиому сигнатуры, и распознаёт верификатор только тогда, когда имя получателя буквально содержит signature — поэтому sig.verify(admin, msg) не распознаётся как гейтирование, тогда как не связанная с этим проверка сигнатуры в другом месте большого метода может привести к избыточному подавлению. Это всё или ничего для каждого метода.
Аутентификация отправителя основана на имени и только в пределах одного метода. O1JS_UNCONSTRAINED_SENDER подавляется, когда this.sender.getAndRequireSignature() или AccountUpdate.createSigned(<that sender>) появляется в том же теле @method. Требование подписи, которое находится только в помощнике (this.requireSenderSig() → getAndRequireSignature внутри), не прослеживается — режим отказа — ложное срабатывание на корректном коде, оборачивающем идиому, а не пропуск реальной ошибки.
Кросс-крейтовые помощники Noir распознаются только по соглашению об именовании (без Nargo.toml / разрешения импортов). Предпочтение отдаётся пропуску, а не ложному срабатыванию.
Именно поэтому находки — это отправная точка для ручной проверки, а не доказательства. Переписывание с учётом потоков данных намеренно вне области применения лексического анализатора.
o1js-scan намеренно представляет собой поверхностный лексический проход по одному файлу — без парсера, без потоков данных, без решателя. Именно это делает его свободным от зависимостей и мгновенным в CI, и это же является жёстким потолком. Ограничения выше — не список задач на будущее; это следствия дизайна.
Поэтому стоит явно сказать, что этот инструмент может и не может вам сообщить:
Этот компромисс правильный для линтера, который вы запускаете на каждом коммите. Если вы работаете над чем-то, где разница имеет значение — протокол, содержащий реальную ценность, схема, в которой вы не можете позволить себе ошибиться — относитесь к этому как к первому проходу и закладывайте бюджет на настоящую проверку.
Для более глубокого анализа отдельный полный сканер поддерживается в репозитории audit-engine-cli. o1js-scan — это намеренно лёгкий, открытый сканер; проприетарные знания о детектировании и детали реализации полного сканера здесь не воспроизводятся. Для доступа или более полной проверки схемы обращайтесь: [email protected].
Установленный CLI анализирует файлы локально. У него нет телеметрии, сетевого клиента, учётной записи или шага загрузки, а его среда выполнения Python не имеет сторонних зависимостей. Запуск o1js-scan path/to/private-repo никуда не отправляет исходный код или находки.
Как и логи компилятора, вывод сканера может содержать пути, идентификаторы и фрагменты исходного кода. SARIF также указывает точные местоположения в репозитории, а GitHub Action загружает его в GitHub code scanning. Используйте те же средства контроля доступа к репозиторию и CI, которые вы уже используете для сканируемого исходного кода.
Хотите внести полезный отчёт о ложном срабатывании или пропущенном обнаружении, не делясь приложением? Воспроизведите синтаксис с вымышленными именами и константами, удаляйте бизнес-логику по одной инструкции за раз и убедитесь, что синтетический фрагмент всё ещё срабатывает по тому же правилу, прежде чем публиковать его. Руководство по безопасному для приватности вкладу содержит конкретный чек-лист и несколько способов помочь сообществу o1js, не раскрывая приватную схему.
Эта граница не мешает открытому сканеру становиться лучше. Публичная документация и репозитории o1js могут поддерживать новые правила и фикстуры совместимости; синтетические примеры могут тестировать ложные срабатывания и пропущенные ограничения; а устойчивость парсера, диагностика, SARIF, производительность, упаковка и калибровка — всё это может улучшаться без публикации приватной техники аудита или клиентского кода. Открытый сканер должен делать независимо объяснимые утверждения; приватные исследования могут оставаться в отдельном движке аудита.
Квантовый риск связан с безопасностью схем, но это не правило о пропущенном ограничении. o1js-scan не определяет, соответствует ли подпись, хеш, обязательство, система доказательств Kimchi или сама Mina постквантовой цели безопасности. Эти ответы зависят от конкретного примитива и параметров, предположений о платформе, требуемого срока службы развёртывания и его плана миграции — а не просто от идентификатора TypeScript, который может увидеть лексический сканер.
Вдохновлённое Qubit or Not Qubit от O(1) Labs, руководство по постквантовой проверке превращает эту границу в специфичный для o1js инвентарь и чек-лист крипто-гибкости. Используйте его вместе с этим сканером, а не интерпретируйте чистый прогон как постквантовую оценку.
Работает на o1js 1.x, 2.x и 3.x, включая хардфорк Mesa, на который нацелен o1js 3.0.0. o1js-scan анализирует исходный код TypeScript как текст и не имеет зависимости времени выполнения от o1js — ничего не привязано к версии. Он опирается на современный API предусловий require* (getAndRequireEquals, requireEquals, requireSignature, getAndRequireSignature), декораторы @method / @method() / @method.returns(...), аннотированные поля @state, this.send({...}), низкоуровневые переводы AccountUpdate.balance.subInPlace(...) и Permissions.*. Устоявшиеся формы остаются совместимыми на границах 1.x → 2.x → 3.x, при этом сканер также принимает недавно задокументированные варианты декораторов и низкоуровневых переводов. Идиома аутентификации владельца 2.x this.sender.getAndRequireSignature() распознаётся как гейтирование по сигнатуре. (Устаревшие предусловия assertEquals по-прежнему принимаются, так что старый код тоже не ломается.)
Ломающие изменения Mesa — все на уровне времени выполнения и протокола: удаление Transaction.setFeePerSnarkCost() и констант TransactionCost.*, новая форма VerificationKey.toJSON(), перегенерированные ключи верификации, MAX_ZKAPP_STATE_FIELDS, поднятый с 8 до 32, и формат транзакций mina-signer v4. Ни одно из них не переименовывает API, по которому сопоставляет этот сканер, поэтому ни одно правило не изменилось для Mesa, и это проверено, а не заявлено. scripts/o1js_release_matrix.sh сканирует два закреплённых релиза o1js, straddling границу протокола — 2.15.0 (9620ef08, последний релиз 2.x) и 3.0.0 (cc18a919, Mesa) — и сравнивает каждую находку с tests/fixtures/o1js_release_matrix.json:
| Релиз | Находки | HIGH | MEDIUM | LOW | Файлы |
|---|---|---|---|---|---|
| o1js 2.15.0 | 36 | 8 | 26 | 2 | 18 |
| o1js 3.0.0 (Mesa) | 39 | 8 | 29 | 2 | 19 |
33 находки идентичны по обе стороны границы, ни одна не потеряна, и все три новые находятся в src/examples/zkapps/big-state-zkapp.ts — пример с 32 полями состояния, который существует только потому, что Mesa поднял MAX_ZKAPP_STATE_FIELDS. Эта дельта закреплена тестом, поэтому она не может незаметно дрейфовать. Матрица запускается при каждой сборке CI; еженедельная задача o1js-upstream-canary дополнительно отслеживает o1js на HEAD, опережая любой релиз.
Эквивалентные написания ограничений нормализуются для анализа: экземплярный assertEquals(...), статический Provable.assertEqual(Type, ...) и цепочки равенства equals(...).assertTrue() привязывают одни и те же операнды. Извлечение методов сбалансировано по скобкам после маскирования комментариев и строк с сохранением длины и принимает многострочные декораторы, вложенные типы параметров в форме колбэков, модификаторы доступа TypeScript и многострочные псевдонимы идентичности (включая формы в скобках и as Type).
Анализ Noir нацелен на синтаксис Noir, используемый проектами Aztec / nargo (.nr); он не вызывает nargo и не компилирует схемы.
Это лексический анализатор, а не полноценный парсер TypeScript или Noir — исходники o1js и Noir разделены скобками и поддаются обработке регулярными выражениями, а вывод предназначен для сортировки человеком. Это делает его свободным от зависимостей и мгновенным для запуска в CI. Находки — это отправная точка для проверки, а не доказательства.
Вклад приветствуется — новые семейства правил, больше защит от ложных срабатываний и архетипы калибровки из реального мира — всё это ценно. См. CONTRIBUTING.md.
О предлагаемом пути от листинга в Community Packages до проверки в виде advisory в репозитории o1js см. готовое к отправке предложение по интеграции в upstream o1js.
Запустите тесты и линтер с помощью:```bash pip install -e ".[dev]" pytest # unit tests + Noir/o1js corpus ruff check . # lint npm run format:check # prettier, npm wrapper only
## Лицензия
Apache-2.0. См. [`LICENSE`](https://github.com/auditinfra-io/o1js-scan/blob/main/LICENSE).