revise May 2023 static analysis articles
Build and deploy / deploy (push) Successful in 16s

This commit is contained in:
2026-07-31 14:49:05 +03:00
parent b56f7e94b9
commit bb964e1f54
7 changed files with 833 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий # Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 190 из 358 созданных материалов. Остальные 168 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. На 31 июля 2026 года строгий аудит проходит 193 из 358 созданных материалов. Остальные 165 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия ## Одна партия
+38
View File
@@ -0,0 +1,38 @@
# П63 · 2023-05 · Статический анализ — три прохода саморевью
## Рамка пакета
- Slug: `editorial-2023-05-practice-static-analysis`, `editorial-2023-05-mechanism-static-analysis`, `editorial-2023-05-field-static-analysis`.
- Голос: М6, май 2023. Автор пишет как инженер надёжности и безопасности: различает контракт инструмента, project context и policy decision; не заменяет их риторикой, личным опытом или непроверенными цифрами.
- Три статьи не повторяют друг друга: practice разбирает классификацию шумного сигнала; mechanism отделяет SARIF, rule, result и context record; field фиксирует keep/tune/scoped suppress, owner, expiry и rollback.
- Sidecar создаёт ровно пять файлов: этот review, `web/scripts/upgrade-2023-05.mjs` и три SVG с префиксом `static-analysis-2023-`. Скрипт не записывает `articles.json`, registry, README, очередь, Git или чужие файлы.
- Fixture использует только `synthetic-static-analysis-input-v1`. URI, строка, ruleId, revision, fingerprint, message и context — учебные значения, созданные в памяти. Они не получены из репозитория, SARIF-файла, scanner, CI, CVE, trace, baseline или production runtime.
## Проход 1 — источники, факты и границы
- Источники перепроверены 31.07.2026 по первичным или официальным страницам; все были опубликованы до мая 2023. [OASIS SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/sarif-v2.1.0-os.html) имеет статус OASIS Standard и дату 27.03.2020; рядом указан его [JSON Schema](https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json). [Semgrep v1.20.0](https://github.com/semgrep/semgrep/releases/tag/v1.20.0) опубликован 28.04.2023; в [исходнике CLI с этим тегом версии](https://github.com/semgrep/semgrep/blob/v1.20.0/cli/src/semgrep/commands/scan.py#L502-L506) объявлена опция `--sarif`. [NIST SP 800-218 SSDF v1.1](https://doi.org/10.6028/NIST.SP.800-218) имеет статус Final от 03.02.2022.
- Формулировки разграничены: SARIF описан как format interchange, а не как модель application; Semgrep source подтверждает наличие CLI output option в указанной версии, а не запуск этой версии здесь; SSDF используется как рамка владения и evidence, а не как доказательство эффективности правил.
- Одна опасная категория названа без ложного verdict: конструкция команды из недоверенного значения. Rule match не назван finding, CVE, доказанной достижимостью, exploitability или production risk. У result сохраняются `sourceRead: not-performed-by-fixture`, `scannerExecution: not-performed-by-fixture`, `reachability: not-assessed-by-fixture` и `securityOutcome: not-determined-by-fixture`.
- `buildSyntheticSarifLog()` строит один учебный SARIF 2.1.0 log только в памяти; `classifySyntheticSignal()` принимает отдельную context record и возвращает `context-incomplete`, если нет asset, entry point, trust boundary, owner или release scope. `planSyntheticRuleDecision()` допускает только keep, tune и scoped suppress; global disable не является допустимым action. После независимой приёмки он принимает только календарные ISO-даты, требует `reviewBy <= expiresOn` для suppress и отклоняет tune с прежней revision. `rollbackSyntheticRuleDecision()` возвращает только state учебного плана и не заявляет config effect.
- В fixture 24 детерминированные assertion: есть positive path и negative cases для неверного kind, другой версии SARIF, отсутствующей trust boundary, global disable, suppress без scope/expiry, несуществующей календарной даты, review после expiry и tune без требуемых evidence или с прежней revision. В коде отсутствуют file I/O, network, shell execution, scanner invocation, package installation, реальный исходный код и real-world output.
## Проход 2 — голос, полнота и объём
- Первые два абзаца каждой статьи сначала называют наблюдаемую проблему и цену: потеря сигнала после глобального отключения; ошибка переноса SARIF-полей в project decision; отсутствие owner/scope/rollback у исключения. Затем материал проходит по маршруту «симптом → причина → проверка → действие».
- В каждом материале есть отдельная доступная таблица, invocable synthetic fixture, упорядоченная последовательность, содержательная SVG-фигура с `alt` и подписью, ограничения модели, rollback и следующий шаг. Rule YAML явно помечен учебным fragment и не выдаётся за запущенное правило.
- `npm run audit:draft -- scripts/upgrade-2023-05.mjs` измерил основной текст без списка источников: practice — 8 253 знака, mechanism — 8 382, field — 9 442. Все три статьи находятся в требуемом диапазоне 5–15 тыс. и в целевом диапазоне 8–10 тыс. знаков для этой темы.
- Редакционный проход убрал универсальные обещания и несуществующий опыт: нет заявлений об actual false-positive rate, coverage, real findings, CVE, сканировании, runtime, эффективности policy или результатах команды. М6-язык называет объекты и границы: rule revision, SARIF version, result fingerprint, trust boundary, owner, scope, expiry, rule diff и config rollback.
## Проход 3 — визуалы, безопасность и выпуск
- `triage-funnel` объясняет путь от rule/result через форму и context к разрешённым решениям; `rule-context` разделяет владельцев данных; `decision-gate` показывает evidence для keep/tune/suppress, отдельный масштаб global policy и rollback. После первого рендера узкие подписи во второй и третьей схеме были перепроектированы; повторный просмотр на 375 px подтвердил, что labels не обрезаются и последовательность читается.
- Все три SVG прошли `xmllint --noout`. Safety scan не нашёл `script`, `foreignObject`, inline event handler, `javascript:` или `data:image`. В SVG нет внешних ресурсов, пользовательского ввода, интерактивного кода или embedded raster.
- Sharp повторно отрендерил фигуры на ширине 375 px и ручной просмотр PNG подтвердил видимые заголовки, стрелки, критичные поля и нижние оговорки. Фигуры описывают synthetic policy contract, не scanner report и не production trace.
- Выпускной проход подтверждает только sidecar: `node --check` — PASS; fixture — PASS 24/24 после независимого model review; draft audit — PASS; import-safe export содержит ровно три revision без `date`/`author`; XML, SVG safety scan и Sharp review — PASS. Registry, README, `articles.json`, очередь, Git, commit и push не затрагивались. Интеграцию и отдельную редакторскую приёмку оставлены главному агенту.
## Приёмка главного редактора
- Источники сверены отдельно от текста: OASIS SARIF подтверждает статус OASIS Standard и дату 27.03.2020; GitHub API подтверждает публикацию Semgrep v1.20.0 28.04.2023, а raw source того же тега — опцию `--sarif`; NIST CSRC фиксирует SP 800-218 Final от 03.02.2022. Ни один из этих источников не используется как свидетельство запуска анализатора в этом репозитории.
- Независимый model review нашёл два пропуска. План раньше принимал невозможную календарную дату и permit-ил `tune` с прежней rule revision; для suppress также не было отношения между `reviewBy` и `expiresOn`. Добавлены строгая проверка ISO-даты, правило `reviewBy <= expiresOn`, запрет на неизменённую revision и три negative case. Fixture теперь проходит 24/24, а не только проверяет наличие непустых строк.
- Визуальный review на 375 px подтвердил читаемость трёх SVG. На воронке triage диагональный крест пересекал подпись о global disable; его заменили компактной иконкой, чтобы запрет и причина не конкурировали за один слой.
- После подключения ровно трёх revision строгий audit подтвердил 8 426 / 8 382 / 9 814 знаков, по одной фигуре, таблице и примеру в каждой статье; импортный registry содержит 184 уникальные revision. Production build прошёл с 374 статическими страницами. `articles.json` и чужие изменения не редактировались.
+2
View File
@@ -59,6 +59,7 @@ import { revisions as january2023Revisions } from '../scripts/upgrade-2023-01.mj
import { revisions as february2023Revisions } from '../scripts/upgrade-2023-02.mjs'; import { revisions as february2023Revisions } from '../scripts/upgrade-2023-02.mjs';
import { revisions as march2023Revisions } from '../scripts/upgrade-2023-03.mjs'; import { revisions as march2023Revisions } from '../scripts/upgrade-2023-03.mjs';
import { revisions as april2023Revisions } from '../scripts/upgrade-2023-04.mjs'; import { revisions as april2023Revisions } from '../scripts/upgrade-2023-04.mjs';
import { revisions as may2023Revisions } from '../scripts/upgrade-2023-05.mjs';
// This layer replaces archived source entries without losing their stable slug and date. // This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [ export const editorialRevisions = [
@@ -123,4 +124,5 @@ export const editorialRevisions = [
...february2023Revisions, ...february2023Revisions,
...march2023Revisions, ...march2023Revisions,
...april2023Revisions, ...april2023Revisions,
...may2023Revisions,
]; ];
@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="575" viewBox="0 0 960 575" role="img" aria-labelledby="title desc">
<title id="title">Гейт решения для шумного правила статического анализа</title>
<desc id="desc">После проверки rule, result и context выбирается одно из трёх действий: keep, tune или ограниченное suppress. Для suppress требуются точный fingerprint и срок. Глобальное отключение находится за пределами этого гейта, rollback возвращает предыдущую видимость учебного плана.</desc>
<rect width="960" height="575" rx="32" fill="#F8FAFC"/>
<rect x="32" y="28" width="896" height="66" rx="18" fill="#312E81"/>
<text x="64" y="68" fill="#F8FAFC" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="28" font-weight="700">Явное решение вместо выключателя</text>
<text x="64" y="86" fill="#DDD6FE" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">Гейт не меняет CI: он фиксирует, что именно будет проверено и как вернуться назад</text>
<rect x="48" y="132" width="248" height="146" rx="20" fill="#E0F2FE" stroke="#0284C7" stroke-width="2"/>
<text x="74" y="171" fill="#075985" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="22" font-weight="700">Входная запись</text>
<text x="74" y="205" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17">rule revision + result fingerprint</text>
<text x="74" y="233" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17">asset + boundary + owner + scope</text>
<text x="74" y="260" fill="#075985" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="14" font-weight="700">неполный context → сначала собрать</text>
<path d="M310 205H361" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round"/>
<path d="M353 193L369 205L353 217" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="470,126 570,205 470,284 370,205" fill="#FEF3C7" stroke="#D97706" stroke-width="2"/>
<text x="421" y="198" fill="#92400E" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18" font-weight="700">context</text>
<text x="427" y="222" fill="#92400E" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18" font-weight="700">полный?</text>
<path d="M583 205H626" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round"/>
<path d="M618 193L634 205L618 217" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="648" y="132" width="264" height="146" rx="20" fill="#DCFCE7" stroke="#16A34A" stroke-width="2"/>
<text x="675" y="171" fill="#166534" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="22" font-weight="700">Evidence решения</text>
<text x="675" y="205" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17">owner · reason · reviewBy</text>
<text x="675" y="233" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17">scope · expiry — для suppress</text>
<text x="675" y="260" fill="#166534" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="14" font-weight="700">applied: not-applied-by-fixture</text>
<path d="M780 292V327" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round"/>
<path d="M768 319L780 335L792 319" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="350" width="240" height="118" rx="20" fill="#E0E7FF" stroke="#4F46E5" stroke-width="2"/>
<text x="76" y="390" fill="#3730A3" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="24" font-weight="700">keep</text>
<text x="76" y="423" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17">сигнал остаётся видимым</text>
<text x="76" y="450" fill="#475569" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">следующий шаг: triage</text>
<rect x="312" y="350" width="240" height="118" rx="20" fill="#F3E8FF" stroke="#9333EA" stroke-width="2"/>
<text x="340" y="390" fill="#6B21A8" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="24" font-weight="700">tune</text>
<text x="340" y="423" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17">revision + rule diff</text>
<text x="340" y="450" fill="#475569" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">rollback: предыдущий diff</text>
<rect x="576" y="350" width="240" height="118" rx="20" fill="#FFF7ED" stroke="#EA580C" stroke-width="2"/>
<text x="604" y="390" fill="#9A3412" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="24" font-weight="700">suppress</text>
<text x="604" y="423" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17">exact fingerprint + expiry</text>
<text x="604" y="450" fill="#475569" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">rollback: снять exception</text>
<rect x="832" y="350" width="80" height="118" rx="20" fill="#FFF1F2" stroke="#E11D48" stroke-width="2"/>
<path d="M844 366L900 450M900 366L844 450" stroke="#E11D48" stroke-width="6" stroke-linecap="round"/>
<text x="847" y="391" fill="#9F1239" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12" font-weight="700">global</text>
<text x="843" y="409" fill="#9F1239" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12" font-weight="700">policy</text>
<text x="850" y="442" fill="#9F1239" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12">отдельно</text>
<rect x="48" y="498" width="864" height="42" rx="16" fill="#0F172A"/>
<text x="76" y="525" fill="#F8FAFC" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">Rollback возвращает видимость учебного плана; реальная конфигурация, CI и security outcome не меняются fixture.</text>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="590" viewBox="0 0 960 590" role="img" aria-labelledby="title desc">
<title id="title">Границы данных между правилом, SARIF result и контекстом</title>
<desc id="desc">Схема разделяет поля, которые описывает правило, формат SARIF, результат и location, от отдельного проектного контекста. Явное решение появляется только после соединения слоёв.</desc>
<rect width="960" height="590" rx="32" fill="#F8FAFC"/>
<rect x="32" y="28" width="896" height="66" rx="18" fill="#172554"/>
<text x="64" y="68" fill="#F8FAFC" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="28" font-weight="700">SARIF и context: разные владельцы фактов</text>
<text x="64" y="86" fill="#BFDBFE" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">Формат передаёт сигнал; проектная запись объясняет, что с ним делать</text>
<rect x="48" y="142" width="180" height="292" rx="20" fill="#E0F2FE" stroke="#0284C7" stroke-width="2"/>
<text x="72" y="181" fill="#075985" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="23" font-weight="700">Rule</text>
<rect x="72" y="201" width="143" height="27" rx="13" fill="#BAE6FD"/>
<text x="86" y="220" fill="#075985" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13" font-weight="700">policy author</text>
<text x="72" y="264" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">id</text>
<text x="72" y="296" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">revision</text>
<text x="72" y="328" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">category</text>
<text x="72" y="360" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">default level</text>
<text x="72" y="402" fill="#475569" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">называет гипотезу</text>
<path d="M242 288H256" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round"/>
<path d="M248 276L264 288L248 300" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="270" y="142" width="180" height="292" rx="20" fill="#FEF3C7" stroke="#D97706" stroke-width="2"/>
<text x="294" y="181" fill="#92400E" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="23" font-weight="700">SARIF log</text>
<rect x="294" y="201" width="132" height="27" rx="13" fill="#FDE68A"/>
<text x="306" y="220" fill="#92400E" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13" font-weight="700">tool producer</text>
<text x="294" y="264" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">version 2.1.0</text>
<text x="294" y="296" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">tool.driver</text>
<text x="294" y="328" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">rules[]</text>
<text x="294" y="360" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">results[]</text>
<text x="294" y="402" fill="#475569" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">передаёт структуру</text>
<path d="M464 288H478" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round"/>
<path d="M470 276L486 288L470 300" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="492" y="142" width="180" height="292" rx="20" fill="#F3E8FF" stroke="#9333EA" stroke-width="2"/>
<text x="516" y="181" fill="#6B21A8" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="23" font-weight="700">Result</text>
<rect x="516" y="201" width="132" height="27" rx="13" fill="#E9D5FF"/>
<text x="528" y="220" fill="#6B21A8" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13" font-weight="700">analysis output</text>
<text x="516" y="264" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">ruleId + level</text>
<text x="516" y="296" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">message</text>
<text x="516" y="328" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">fingerprint</text>
<text x="516" y="360" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">URI + line</text>
<text x="516" y="402" fill="#475569" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">указывает на место</text>
<path d="M686 288H700" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round"/>
<path d="M692 276L708 288L692 300" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="714" y="142" width="198" height="292" rx="20" fill="#DCFCE7" stroke="#16A34A" stroke-width="2"/>
<text x="738" y="181" fill="#166534" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="23" font-weight="700">Контекст</text>
<rect x="738" y="201" width="150" height="27" rx="13" fill="#BBF7D0"/>
<text x="751" y="220" fill="#166534" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13" font-weight="700">component owner</text>
<text x="738" y="264" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">asset</text>
<text x="738" y="296" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">entry point</text>
<text x="738" y="328" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">boundary</text>
<text x="738" y="360" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">owner + scope</text>
<text x="738" y="402" fill="#166534" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15" font-weight="700">добавляет reviewer</text>
<path d="M170 471H794" fill="none" stroke="#475569" stroke-width="4" stroke-linecap="round"/>
<path d="M786 459L802 471L786 483" fill="none" stroke="#475569" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="274" y="446" width="420" height="52" rx="18" fill="#0F172A"/>
<text x="345" y="479" fill="#F8FAFC" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700">Решение: keep · tune · suppress</text>
<text x="48" y="548" fill="#64748B" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">Synthetic схема: rule и SARIF не доказывают запуск, reachability, exploitability или outcome безопасности.</text>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="560" viewBox="0 0 960 560" role="img" aria-labelledby="title desc">
<title id="title">Воронка triage для сигнала статического анализа</title>
<desc id="desc">Правило и SARIF result проходят через проверку формы и контекста. На выходе разрешены keep, tune и ограниченное suppress; глобальное отключение вынесено за пределы обычного решения.</desc>
<rect width="960" height="560" rx="32" fill="#F8FAFC"/>
<rect x="32" y="28" width="896" height="66" rx="18" fill="#0F172A"/>
<text x="64" y="68" fill="#F8FAFC" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="32" font-weight="700">Разбор сигнала: сначала контекст</text>
<text x="64" y="86" fill="#CBD5E1" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">Учебная воронка: сигнал не становится security verdict автоматически</text>
<rect x="48" y="132" width="196" height="214" rx="20" fill="#E0F2FE" stroke="#0284C7" stroke-width="2"/>
<text x="72" y="172" fill="#075985" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="22" font-weight="700">1. Вход</text>
<text x="72" y="207" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">rule id + revision</text>
<text x="72" y="236" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">SARIF result</text>
<text x="72" y="265" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">URI + строка</text>
<rect x="72" y="288" width="145" height="32" rx="16" fill="#BAE6FD"/>
<text x="87" y="310" fill="#075985" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="14" font-weight="700">ещё не finding</text>
<path d="M258 239H302" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round"/>
<path d="M294 227L310 239L294 251" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="316" y="132" width="196" height="214" rx="20" fill="#FEF3C7" stroke="#D97706" stroke-width="2"/>
<text x="340" y="172" fill="#92400E" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="22" font-weight="700">2. Форма</text>
<text x="340" y="207" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">версия формата</text>
<text x="340" y="236" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">rule ↔ result</text>
<text x="340" y="265" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">fingerprint</text>
<rect x="340" y="288" width="145" height="32" rx="16" fill="#FDE68A"/>
<text x="354" y="310" fill="#92400E" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="14" font-weight="700">контракт, не риск</text>
<path d="M526 239H570" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round"/>
<path d="M562 227L578 239L562 251" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="584" y="132" width="196" height="214" rx="20" fill="#DCFCE7" stroke="#16A34A" stroke-width="2"/>
<text x="608" y="172" fill="#166534" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="22" font-weight="700">3. Контекст</text>
<text x="608" y="207" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">asset + entry point</text>
<text x="608" y="236" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">trust boundary</text>
<text x="608" y="265" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18">owner + scope</text>
<rect x="608" y="288" width="145" height="32" rx="16" fill="#BBF7D0"/>
<text x="623" y="310" fill="#166534" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="14" font-weight="700">собирает человек</text>
<path d="M794 239H838" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round"/>
<path d="M830 227L846 239L830 251" fill="none" stroke="#64748B" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="806" y="132" width="106" height="214" rx="20" fill="#EDE9FE" stroke="#7C3AED" stroke-width="2"/>
<text x="826" y="172" fill="#5B21B6" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="22" font-weight="700">4.</text>
<text x="826" y="202" fill="#5B21B6" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18" font-weight="700">Выбор</text>
<text x="826" y="235" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17">keep</text>
<text x="826" y="263" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17">tune</text>
<text x="826" y="291" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17">suppress</text>
<rect x="48" y="390" width="564" height="104" rx="20" fill="#FFFFFF" stroke="#CBD5E1" stroke-width="2"/>
<text x="76" y="422" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="19" font-weight="700">Перед действием: owner · причина · reviewBy</text>
<text x="76" y="451" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18" font-weight="700">точный scope и rollback</text>
<text x="76" y="478" fill="#475569" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">Неполный context оставляет сигнал видимым и создаёт задачу на недостающий факт.</text>
<rect x="642" y="390" width="270" height="104" rx="20" fill="#FFF1F2" stroke="#E11D48" stroke-width="2"/>
<circle cx="674" cy="428" r="14" fill="#E11D48"/>
<path d="M668 422L680 434M680 422L668 434" stroke="#FFFFFF" stroke-width="3" stroke-linecap="round"/>
<text x="699" y="435" fill="#9F1239" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="19" font-weight="700">не: global disable</text>
<text x="699" y="463" fill="#9F1239" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">другой масштаб решения</text>
<text x="48" y="530" fill="#64748B" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15">Synthetic схема: она не показывает запуск scanner, реальные findings, coverage или outcome безопасности.</text>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

+611
View File
@@ -0,0 +1,611 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const sources = {
sarif: {
title: 'OASIS: Static Analysis Results Interchange Format (SARIF) Version 2.1.0, OASIS Standard, 27 марта 2020',
url: 'https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/sarif-v2.1.0-os.html',
note: 'нормативная спецификация формата результата статического анализа. Она задаёт формат обмена, а не достоверность отдельного result и не решение об исправлении.',
},
sarifSchema: {
title: 'OASIS: SARIF 2.1.0 JSON Schema, 27 марта 2020',
url: 'https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json',
note: 'схема формата, доступная вместе со стандартом. Этот fixture собирает учебный объект в памяти и не выполняет валидацию внешним schema validator.',
},
semgrepRelease: {
title: 'Semgrep v1.20.0: официальный release, 28 апреля 2023',
url: 'https://github.com/semgrep/semgrep/releases/tag/v1.20.0',
note: 'версионная точка отсчёта, опубликованная до мая 2023. Она нужна для воспроизводимости, но не говорит, как конкретное правило поведёт себя в вашем репозитории.',
},
semgrepSarif: {
title: 'Semgrep v1.20.0: исходник CLI scan.py, опция --sarif',
url: 'https://github.com/semgrep/semgrep/blob/v1.20.0/cli/src/semgrep/commands/scan.py#L502-L506',
note: 'официальный исходник фиксированной версии: CLI объявляет output format SARIF. Он не подтверждает, что этот учебный fixture запускал Semgrep или получил finding из исходного кода.',
},
ssdf: {
title: 'NIST SP 800-218, Secure Software Development Framework Version 1.1, Final, 3 февраля 2022',
url: 'https://doi.org/10.6028/NIST.SP.800-218',
note: 'официальный документ по безопасной разработке, доступный до мая 2023. Здесь он служит рамкой для владения решением и evidence, а не доказательством эффективности правила.',
},
};
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function revision(meta, parts, sourceItems) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList(sourceItems);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 8000 || proseLength > 10500) {
throw new Error(meta.slug + ': основной текст должен занимать 8 000–10 500 знаков, сейчас ' + proseLength);
}
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const SYNTHETIC_KIND = 'synthetic-static-analysis-input-v1';
const SYNTHETIC_RULE_ID = 'demo.untrusted-command-construction';
const SYNTHETIC_FINGERPRINT = 'synthetic-fingerprint-command-001';
/**
* Это единственный учебный вход пакета. Он создан в памяти: не читает исходники,
* конфигурацию, Git, CI, SARIF-файл или вывод анализатора. Имена, URI, строки,
* fingerprint и контекст намеренно synthetic. Код не запускает Semgrep, другой
* scanner, shell-команду, сеть или production-процесс; он не сообщает finding,
* CVE, покрытие, достижимость, exploitability либо эффективность настоящего правила.
*/
export const syntheticStaticAnalysisInput = Object.freeze({
kind: SYNTHETIC_KIND,
rule: Object.freeze({
id: SYNTHETIC_RULE_ID,
revision: 'demo-rule-revision-3',
category: 'untrusted-command-construction',
defaultLevel: 'warning',
message: 'Проверить synthetic передачу недоверенного значения в построение команды',
source: 'synthetic-rule-definition',
}),
occurrence: Object.freeze({
uri: 'src/demo-command.js',
startLine: 14,
expression: 'runShell(demoRequest.command)',
fingerprint: SYNTHETIC_FINGERPRINT,
source: 'synthetic-memory-only',
}),
context: Object.freeze({
asset: 'demo-export-job',
entryPoint: 'demo-http-handler',
trustBoundary: 'demo-request-parameter',
owner: 'demo-security-owner',
releaseScope: 'demo-change-2023-05',
source: 'synthetic-context-record',
}),
});
function hasText(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function isIsoDate(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return false;
}
const parsed = new Date(value + 'T00:00:00.000Z');
return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value;
}
function frozenCopy(value) {
return Object.freeze({ ...value });
}
/**
* Строит минимальный учебный SARIF 2.1.0 log. Он демонстрирует поле version,
* tool.driver.rules, result.ruleId и location, но не является экспортом scanner.
* Функция не утверждает, что URI существует, строка прочитана или код исполняется.
*/
export function buildSyntheticSarifLog(input) {
if (!input || input.kind !== SYNTHETIC_KIND) {
return Object.freeze({ accepted: false, reason: 'synthetic-kind-required' });
}
const rule = input.rule;
const occurrence = input.occurrence;
if (!rule || !hasText(rule.id) || !hasText(rule.revision) || !hasText(rule.category) || !hasText(rule.message)) {
return Object.freeze({ accepted: false, reason: 'synthetic-rule-fields-required' });
}
if (!occurrence || !hasText(occurrence.uri) || !Number.isInteger(occurrence.startLine) || occurrence.startLine < 1 || !hasText(occurrence.fingerprint)) {
return Object.freeze({ accepted: false, reason: 'synthetic-occurrence-fields-required' });
}
const log = Object.freeze({
$schema: 'https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json',
version: '2.1.0',
runs: Object.freeze([
Object.freeze({
tool: Object.freeze({
driver: Object.freeze({
name: 'Synthetic Static Analysis Fixture',
version: '0.0.0-synthetic',
rules: Object.freeze([
Object.freeze({
id: rule.id,
name: rule.category,
shortDescription: Object.freeze({ text: rule.message }),
defaultConfiguration: Object.freeze({ level: rule.defaultLevel }),
properties: Object.freeze({
tags: Object.freeze(['synthetic', 'review-required', rule.category]),
ruleRevision: rule.revision,
}),
}),
]),
}),
}),
results: Object.freeze([
Object.freeze({
ruleId: rule.id,
ruleIndex: 0,
level: rule.defaultLevel,
message: Object.freeze({ text: rule.message }),
partialFingerprints: Object.freeze({ primaryLocationLineHash: occurrence.fingerprint }),
locations: Object.freeze([
Object.freeze({
physicalLocation: Object.freeze({
artifactLocation: Object.freeze({ uri: occurrence.uri, uriBaseId: 'SYNTHETIC_SOURCE_ROOT' }),
region: Object.freeze({ startLine: occurrence.startLine }),
}),
}),
]),
properties: Object.freeze({
inputKind: SYNTHETIC_KIND,
sourceRead: 'not-performed-by-fixture',
scannerExecution: 'not-performed-by-fixture',
reachability: 'not-assessed-by-fixture',
exploitability: 'not-determined-by-fixture',
}),
}),
]),
}),
]),
});
return Object.freeze({ accepted: true, source: 'synthetic-memory-only', log });
}
function readSyntheticResult(sarif) {
const run = sarif?.runs?.[0];
const rule = run?.tool?.driver?.rules?.[0];
const result = run?.results?.[0];
return { run, rule, result };
}
/**
* Классифицирует только форму учебного SARIF и отдельную synthetic context record.
* Результат deliberately не превращает совпадение правила в security finding.
*/
export function classifySyntheticSignal(sarifResult, context) {
const base = { kind: 'synthetic-signal-classification-v1', securityOutcome: 'not-determined-by-fixture' };
if (!sarifResult?.accepted || sarifResult.source !== 'synthetic-memory-only') {
return Object.freeze({ ...base, accepted: false, reason: 'accepted-synthetic-sarif-required' });
}
const { log } = sarifResult;
const { rule, result } = readSyntheticResult(log);
if (log.version !== '2.1.0' || !rule || !result || rule.id !== SYNTHETIC_RULE_ID || result.ruleId !== SYNTHETIC_RULE_ID) {
return Object.freeze({ ...base, accepted: false, reason: 'synthetic-sarif-rule-shape-required' });
}
const location = result.locations?.[0]?.physicalLocation;
if (!location?.artifactLocation?.uri || !Number.isInteger(location?.region?.startLine)) {
return Object.freeze({ ...base, accepted: false, reason: 'synthetic-location-required' });
}
const contextFields = ['asset', 'entryPoint', 'trustBoundary', 'owner', 'releaseScope'];
const missingContext = contextFields.filter((field) => !hasText(context?.[field]));
const record = Object.freeze({
rule: Object.freeze({ id: rule.id, revision: rule.properties.ruleRevision, category: rule.name, level: result.level }),
location: Object.freeze({ uri: location.artifactLocation.uri, startLine: location.region.startLine, source: 'synthetic-memory-only' }),
result: Object.freeze({ fingerprint: result.partialFingerprints.primaryLocationLineHash, message: result.message.text }),
scanner: Object.freeze({ execution: result.properties.scannerExecution, sourceRead: result.properties.sourceRead }),
});
if (missingContext.length > 0) {
return Object.freeze({
...base,
accepted: true,
record,
classification: 'context-incomplete',
missingContext: Object.freeze(missingContext),
decision: 'collect-context-before-changing-rule',
reachability: result.properties.reachability,
});
}
return Object.freeze({
...base,
accepted: true,
record,
classification: 'review-required',
context: frozenCopy(context),
decision: 'keep-rule-visible-and-open-scoped-review',
reachability: result.properties.reachability,
});
}
/**
* Планирует явное решение над учебной классификацией. Он принимает только
* keep, tune или ограниченную по fingerprint suppression; global disable
* намеренно недоступен. План не меняет scanner config, CI, baseline или rule.
*/
export function planSyntheticRuleDecision(classification, proposal) {
const base = { kind: 'synthetic-rule-decision-plan-v1', applied: 'not-applied-by-fixture' };
if (!classification?.accepted || classification.kind !== 'synthetic-signal-classification-v1') {
return Object.freeze({ ...base, accepted: false, reason: 'accepted-synthetic-classification-required' });
}
if (classification.classification !== 'review-required') {
return Object.freeze({ ...base, accepted: false, reason: 'complete-synthetic-context-required' });
}
if (!proposal || proposal.kind !== 'synthetic-rule-decision-proposal-v1') {
return Object.freeze({ ...base, accepted: false, reason: 'synthetic-decision-proposal-required' });
}
if (!['keep', 'tune', 'suppress'].includes(proposal.action)) {
return Object.freeze({ ...base, accepted: false, reason: 'action-must-be-keep-tune-or-suppress' });
}
if (!hasText(proposal.owner) || !hasText(proposal.reason) || !isIsoDate(proposal.reviewBy)) {
return Object.freeze({ ...base, accepted: false, reason: 'owner-reason-and-iso-review-date-required' });
}
const before = Object.freeze({ ruleVisibility: 'visible-in-synthetic-plan', scope: 'rule-default' });
if (proposal.action === 'keep') {
return Object.freeze({
...base,
accepted: true,
before,
action: 'keep',
evidence: Object.freeze({ owner: proposal.owner, reason: proposal.reason, reviewBy: proposal.reviewBy }),
nextAction: 'perform-project-specific-triage',
rollback: before,
securityOutcome: 'not-determined-by-fixture',
});
}
if (proposal.action === 'tune') {
if (!hasText(proposal.ruleRevision) || !hasText(proposal.changeSummary)) {
return Object.freeze({ ...base, accepted: false, reason: 'rule-revision-and-change-summary-required' });
}
if (proposal.ruleRevision === classification.record.rule.revision) {
return Object.freeze({ ...base, accepted: false, reason: 'new-rule-revision-required' });
}
return Object.freeze({
...base,
accepted: true,
before,
action: 'tune',
evidence: Object.freeze({ owner: proposal.owner, reason: proposal.reason, reviewBy: proposal.reviewBy, ruleRevision: proposal.ruleRevision, changeSummary: proposal.changeSummary }),
nextAction: 'review-rule-diff-against-project-context',
rollback: before,
securityOutcome: 'not-determined-by-fixture',
});
}
if (proposal.scope !== 'exact-synthetic-fingerprint' || proposal.fingerprint !== SYNTHETIC_FINGERPRINT || !isIsoDate(proposal.expiresOn)) {
return Object.freeze({ ...base, accepted: false, reason: 'scoped-fingerprint-and-expiry-required' });
}
if (proposal.reviewBy > proposal.expiresOn) {
return Object.freeze({ ...base, accepted: false, reason: 'review-date-must-not-follow-expiry' });
}
return Object.freeze({
...base,
accepted: true,
before,
action: 'suppress',
evidence: Object.freeze({ owner: proposal.owner, reason: proposal.reason, reviewBy: proposal.reviewBy, scope: proposal.scope, fingerprint: proposal.fingerprint, expiresOn: proposal.expiresOn }),
nextAction: 'recheck-scoped-exception-before-expiry',
rollback: before,
securityOutcome: 'not-determined-by-fixture',
});
}
export function rollbackSyntheticRuleDecision(plan) {
if (!plan?.accepted || !plan.rollback) {
return Object.freeze({ restored: false, reason: 'accepted-synthetic-plan-required' });
}
return Object.freeze({
restored: true,
ruleVisibility: plan.rollback.ruleVisibility,
scope: plan.rollback.scope,
configEffect: 'not-applied-by-fixture',
securityOutcome: 'not-determined-by-fixture',
});
}
export function runStaticAnalysisFixture() {
const sarif = buildSyntheticSarifLog(syntheticStaticAnalysisInput);
const classification = classifySyntheticSignal(sarif, syntheticStaticAnalysisInput.context);
const keepPlan = planSyntheticRuleDecision(classification, {
kind: 'synthetic-rule-decision-proposal-v1',
action: 'keep',
owner: 'demo-security-owner',
reason: 'synthetic context is complete but requires a project review',
reviewBy: '2023-05-31',
});
const suppressPlan = planSyntheticRuleDecision(classification, {
kind: 'synthetic-rule-decision-proposal-v1',
action: 'suppress',
owner: 'demo-security-owner',
reason: 'synthetic exception used to demonstrate bounded suppression',
reviewBy: '2023-05-31',
scope: 'exact-synthetic-fingerprint',
fingerprint: SYNTHETIC_FINGERPRINT,
expiresOn: '2023-06-15',
});
const rollback = rollbackSyntheticRuleDecision(suppressPlan);
const invalidInput = buildSyntheticSarifLog({ kind: 'real-scanner-output' });
const malformedSarif = classifySyntheticSignal({ accepted: true, source: 'synthetic-memory-only', log: { version: '2.0.0', runs: [] } }, syntheticStaticAnalysisInput.context);
const missingBoundary = classifySyntheticSignal(sarif, { ...syntheticStaticAnalysisInput.context, trustBoundary: '' });
const globalDisable = planSyntheticRuleDecision(classification, {
kind: 'synthetic-rule-decision-proposal-v1',
action: 'disable-globally',
owner: 'demo-security-owner',
reason: 'not an allowed action',
reviewBy: '2023-05-31',
});
const unscopedSuppression = planSyntheticRuleDecision(classification, {
kind: 'synthetic-rule-decision-proposal-v1',
action: 'suppress',
owner: 'demo-security-owner',
reason: 'missing scope and expiry',
reviewBy: '2023-05-31',
});
const invalidReviewDate = planSyntheticRuleDecision(classification, {
kind: 'synthetic-rule-decision-proposal-v1',
action: 'keep',
owner: 'demo-security-owner',
reason: 'invalid calendar date must not create a review plan',
reviewBy: '2023-02-30',
});
const expiredBeforeReview = planSyntheticRuleDecision(classification, {
kind: 'synthetic-rule-decision-proposal-v1',
action: 'suppress',
owner: 'demo-security-owner',
reason: 'the review must occur before the scoped exception expires',
reviewBy: '2023-06-16',
scope: 'exact-synthetic-fingerprint',
fingerprint: SYNTHETIC_FINGERPRINT,
expiresOn: '2023-06-15',
});
const unchangedRevisionTune = planSyntheticRuleDecision(classification, {
kind: 'synthetic-rule-decision-proposal-v1',
action: 'tune',
owner: 'demo-security-owner',
reason: 'a tune must name a changed rule revision',
reviewBy: '2023-05-31',
ruleRevision: 'demo-rule-revision-3',
changeSummary: 'this summary is not enough without a new revision',
});
const tunePlan = planSyntheticRuleDecision(classification, {
kind: 'synthetic-rule-decision-proposal-v1',
action: 'tune',
owner: 'demo-security-owner',
reason: 'synthetic precision change',
reviewBy: '2023-05-31',
ruleRevision: 'demo-rule-revision-4',
changeSummary: 'require explicit demo boundary marker',
});
return Object.freeze({
assertions: Object.freeze({
inputIsExplicitlySynthetic: syntheticStaticAnalysisInput.kind === SYNTHETIC_KIND && syntheticStaticAnalysisInput.occurrence.source === 'synthetic-memory-only',
sarifIsBuiltInMemory: sarif.accepted === true && sarif.source === 'synthetic-memory-only',
sarifUsesVersion210: sarif.log.version === '2.1.0',
sarifNamesOneRule: sarif.log.runs[0].tool.driver.rules.length === 1 && sarif.log.runs[0].tool.driver.rules[0].id === SYNTHETIC_RULE_ID,
sarifNamesOneSyntheticResult: sarif.log.runs[0].results.length === 1 && sarif.log.runs[0].results[0].ruleId === SYNTHETIC_RULE_ID,
sarifPreservesSyntheticLocation: sarif.log.runs[0].results[0].locations[0].physicalLocation.region.startLine === 14,
fixtureDoesNotReadSource: sarif.log.runs[0].results[0].properties.sourceRead === 'not-performed-by-fixture',
fixtureDoesNotRunScanner: sarif.log.runs[0].results[0].properties.scannerExecution === 'not-performed-by-fixture',
fixtureDoesNotAssessReachability: sarif.log.runs[0].results[0].properties.reachability === 'not-assessed-by-fixture',
completeContextNeedsReview: classification.accepted === true && classification.classification === 'review-required',
classificationDoesNotClaimSecurityOutcome: classification.securityOutcome === 'not-determined-by-fixture',
missingBoundaryIsVisible: missingBoundary.accepted === true && missingBoundary.classification === 'context-incomplete' && missingBoundary.missingContext.includes('trustBoundary'),
invalidKindRejected: invalidInput.accepted === false && invalidInput.reason === 'synthetic-kind-required',
malformedSarifRejected: malformedSarif.accepted === false && malformedSarif.reason === 'synthetic-sarif-rule-shape-required',
keepPlanIsExplicitAndUnapplied: keepPlan.accepted === true && keepPlan.action === 'keep' && keepPlan.applied === 'not-applied-by-fixture',
globalDisableRejected: globalDisable.accepted === false && globalDisable.reason === 'action-must-be-keep-tune-or-suppress',
unscopedSuppressionRejected: unscopedSuppression.accepted === false && unscopedSuppression.reason === 'scoped-fingerprint-and-expiry-required',
invalidReviewDateRejected: invalidReviewDate.accepted === false && invalidReviewDate.reason === 'owner-reason-and-iso-review-date-required',
reviewBeforeExpiryRequired: expiredBeforeReview.accepted === false && expiredBeforeReview.reason === 'review-date-must-not-follow-expiry',
unchangedRuleRevisionRejected: unchangedRevisionTune.accepted === false && unchangedRevisionTune.reason === 'new-rule-revision-required',
scopedSuppressionKeepsFingerprint: suppressPlan.accepted === true && suppressPlan.evidence.fingerprint === SYNTHETIC_FINGERPRINT && suppressPlan.evidence.expiresOn === '2023-06-15',
tuneNamesRuleRevision: tunePlan.accepted === true && tunePlan.evidence.ruleRevision === 'demo-rule-revision-4',
rollbackRestoresOnlySyntheticPlan: rollback.restored === true && rollback.ruleVisibility === 'visible-in-synthetic-plan' && rollback.configEffect === 'not-applied-by-fixture',
rollbackDoesNotClaimSecurity: rollback.securityOutcome === 'not-determined-by-fixture',
}),
samples: Object.freeze({ sarif, classification, keepPlan, suppressPlan, rollback, invalidInput, malformedSarif, missingBoundary, globalDisable, unscopedSuppression, invalidReviewDate, expiredBeforeReview, unchangedRevisionTune, tunePlan }),
});
}
const fixtureCommand = `node web/scripts/upgrade-2023-05.mjs --verify-fixture
# Все URI, строка, fingerprint, правило и context ниже synthetic.
# Команда создаёт deterministic object только в памяти: не читает проект,
# не запускает Semgrep/другой scanner, не открывает сеть и не выводит CVE.
# PASS проверяет контракт SARIF 2.1.0, границы context и решения в учебном плане.
# PASS не доказывает finding, покрытие, достижимость, exploitability или безопасность.`;
const syntheticRuleExample = `# Учебная форма правила: этот fragment не запускается fixture и не относится к проекту.
rules:
- id: demo.untrusted-command-construction
languages: [javascript]
severity: WARNING
message: "Проверить synthetic построение команды"
patterns:
- pattern: runShell($UNTRUSTED)`;
const practice = revision({
slug: 'editorial-2023-05-practice-static-analysis',
title: 'Шумное правило статанализа: не выключать до классификации сигнала',
categories: ['Безопасность', 'Разработка'],
cover: '/assets/editorial/2023/static-analysis-2023-triage-funnel.svg',
excerpt: 'Как разобрать результат опасного правила: отделить форму совпадения от контекста, не назвать его finding раньше времени и не выключить защиту глобально.',
readingMinutes: 12,
}, [
p('Правило статического анализа срабатывает на строках, которые команда считает безопасными в своём контексте. Через несколько повторов появляется просьба выключить правило: оно шумит, отвлекает review и мешает выпуску. Проблема не в самом раздражении. В одном действии смешиваются три разных вопроса: что именно совпало с правилом, какой контекст у этой строки и какое изменение конфигурации действительно нужно.'),
p('Цена глобального выключения видна не в момент удаления правила. Исчезает сигнал для опасной категории — построения команды из недоверенного значения, — и следующий похожий участок больше не попадёт в очередь review. Обратная ошибка тоже дорога: если каждое совпадение объявлять уязвимостью, инженер тратит время на ложную аварийность, а сообщения перестают читать. Рабочий ответ находится между этими крайностями: сначала короткая классификация, затем явное решение с владельцем и сроком пересмотра.'),
h2('Совпадение формы не является выводом о коде'),
p('Опасная категория в этой заметке узкая: правило видит передачу значения из условно недоверенного источника в функцию, которая строит команду. Такая форма заслуживает внимания, но сама строка не говорит, откуда реально пришло значение, выполняется ли ветка, есть ли проверка до вызова и попадает ли код в нужный артефакт. Поэтому нельзя называть SARIF result «подтверждённой уязвимостью» только по ruleId и номеру строки.'),
p('SARIF 2.1.0 — стандартный формат обмена результатами статического анализа. Он может хранить описание tool, rule, result и location. Формат упрощает передачу сигнала между инструментами, но не добавляет контекст, которого анализатор не собрал. В частности, uri и startLine указывают на позицию, а не на реальное исполнение. В статье дальше SARIF нужен как аккуратный контейнер фактов: каждое поле получает отдельный смысл и отдельную границу.'),
table('Первичная классификация одного сигнала', ['Слой', 'Что можно записать', 'Чего не следует писать', 'Следующая проверка'], [
['Правило', 'id, revision, категория, default level', '«правило доказало риск»', 'прочитать intent и diff правила'],
['Result', 'ruleId, message, fingerprint, level', '«найден CVE»', 'сверить result с точной версией правила'],
['Location', 'URI и строка как указатель', '«строка точно исполняется»', 'проверить ревизию исходника и entry point'],
['Контекст', 'asset, boundary, owner, scope релиза', '«контекст очевиден из SARIF»', 'собрать запись у владельца компонента'],
['Решение', 'keep, tune или ограниченное suppress', '«выключить для всех»', 'зафиксировать reason, reviewer и rollback'],
]),
h2('Учебный fixture: формируем только synthetic SARIF'),
p('Ниже запускается код этого пакета. Он создаёт один объект SARIF 2.1.0 в памяти с именем правила <code>demo.untrusted-command-construction</code>, URI <code>src/demo-command.js</code> и строкой 14. Эти значения помечены synthetic. Функция не читает файл с таким именем, не вызывает Semgrep, не выполняет <code>runShell</code> и не подключается к CI. Поэтому PASS — это проверка контракта учебного объекта, а не отчёт об анализе настоящего репозитория.'),
code(fixtureCommand),
p('Внутри <code>buildSyntheticSarifLog()</code> result связывается с ruleId, ruleIndex, message, partial fingerprint и location. Рядом прямо записаны границы: <code>sourceRead: not-performed-by-fixture</code>, <code>scannerExecution: not-performed-by-fixture</code>, <code>reachability: not-assessed-by-fixture</code>. Это важнее красивого JSON: потребитель не должен по умолчанию превращать случайный result в утверждение о запуске кода. Fixture ещё и отвергает другой kind входа, чтобы реальный scanner output нельзя было незаметно подложить под учебную модель.'),
figure('/assets/editorial/2023/static-analysis-2023-triage-funnel.svg', 'Воронка классификации synthetic сигнала: rule и SARIF result проходят через проверку location и project context; на выходе остаются keep, tune или ограниченное suppress, а глобальное выключение исключено.', 'Схема показывает порядок разбора одного synthetic результата. Она не отражает реальные findings, coverage, количество срабатываний, данные проекта или эффективность правила.'),
h2('Контекст нужно собирать отдельно'),
p('Минимальная context record для этого типа сигнала состоит из пяти полей: asset, entry point, trust boundary, owner и release scope. Asset отвечает, какая часть системы обсуждается. Entry point называет предполагаемый вход в сценарий. Trust boundary отмечает, откуда значение стало недоверенным для данной проверки. Owner выбирает человека или роль, которая может подтвердить устройство компонента. Release scope связывает обсуждение с конкретным изменением, а не со всем продуктом.'),
p('Каждое поле можно оставить неизвестным; тогда это надо назвать явно. Если не найден entry point, статус — не «ложное срабатывание», а «контекст неполный». Если неизвестна trust boundary, нельзя писать, что значение безопасно. Если rule сработал в generated fixture, это ещё не ответ про поставленный пакет. Такой стиль записи сдерживает обе ошибки: он не повышает сигнал до security verdict и не скрывает вопрос под словом noise.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> В pull request повторяется один ruleId, а в комментариях есть только «мешает» или «ложный плюс». Зафиксируйте ruleId, revision, result fingerprint, URI и строку, не добавляя вывод о риске.',
'<strong>Причина.</strong> Совпадение синтаксической формы, контекст вызова и политика правила обсуждаются одним предложением. Поэтому reviewer не понимает, что именно предлагается изменить.',
'<strong>Проверка.</strong> Сверьте rule intent и версию анализатора, затем заполните asset, entry point, trust boundary, owner и scope. Для реального проекта этот шаг делает владелец компонента, а не SARIF parser.',
'<strong>Действие.</strong> Если контекст неполный, оставьте result видимым и создайте короткую задачу на сбор данных. Если контекст собран, выберите keep, точечный tune с новой revision либо scoped suppress с валидными reviewBy и expiresOn.',
'<strong>Проверка контракта.</strong> Выполните <code>node web/scripts/upgrade-2023-05.mjs --verify-fixture</code>. PASS обязан сохранить статусы <code>not-assessed</code> и <code>not-determined</code>; если fixture сделал verdict, модель испорчена.',
'<strong>Rollback.</strong> Любое учебное решение хранит прежнее состояние <code>visible-in-synthetic-plan</code>. В настоящем проекте rollback — отдельный diff конфигурации и повторный review, а не надежда на историю CI.',
]),
h2('Почему не начинать с исключения'),
p('Исключение полезно, когда уже известен его объект. Для result это может быть точный fingerprint, конкретный rule revision, причина, owner и дата, когда решение нужно пересмотреть. Даты должны быть календарными, а reviewBy не может наступать после expiresOn: иначе исключение успеет исчезнуть раньше обязательной проверки. Без этих полей suppress — это не документированная граница, а удаление наблюдаемости. Глобальное выключение ещё шире: оно меняет судьбу будущих результатов, которых reviewer пока не видел. Поэтому fixture вообще не принимает action <code>disable-globally</code>; он не даёт объявить широкое действие безопасным только потому, что оно удобно.'),
p('Точечный tune отличается от suppression. Tune меняет саму гипотезу правила: например, добавляет условие о явной boundary marker или уточняет pattern. Значит, в решении нужны revision правила и описание diff. Suppression сохраняет rule, но временно ограничивает обработку одного идентифицируемого result. Эти варианты нельзя подменять: если проблема в неточном pattern, вечное исключение прячет долг правила; если проблема в одном переходном месте, новый rule revision может неоправданно сузить будущие сигналы.'),
h2('Ограничения модели и следующий шаг'),
p('Fixture не знает язык настоящего проекта, конфигурацию анализатора, baseline, generated code, feature flags, права доступа, историю результата, правило подавления, CI и production runtime. Он не проверяет SARIF JSON Schema внешним валидатором и не доказывает, что учебный YAML-фрагмент ниже соответствует конкретному запуску Semgrep. Использованная версия Semgrep 1.20.0 опубликована 28 апреля 2023 года; исходник этой версии объявляет опцию <code>--sarif</code>, но это не аттестация ваших настроек или результата.'),
p('Следующий шаг — взять один реальный ruleId без копирования чувствительных фрагментов кода и заполнить пять полей context record. Затем записать одно из трёх решений: оставить правило, подготовить rule diff или создать ограниченное исключение. Если доказательств пока нет, решение должно звучать короче: «контекст не собран, правило остаётся видимым, владелец и дата проверки назначены». Такая запись не выдаёт опыт за метрику и не заставляет команду верить шумному сообщению на слово.'),
], [sources.sarif, sources.sarifSchema, sources.semgrepRelease, sources.semgrepSarif, sources.ssdf]);
const mechanism = revision({
slug: 'editorial-2023-05-mechanism-static-analysis',
title: 'SARIF без контекста: какие части правила и результата нужны для решения',
categories: ['Безопасность', 'Архитектура'],
cover: '/assets/editorial/2023/static-analysis-2023-rule-context.svg',
excerpt: 'Разбираем границу между SARIF-форматом, правилом, результатом и проектным контекстом: почему один JSON не отвечает, что менять в политике анализа.',
readingMinutes: 13,
}, [
p('После нескольких спорных срабатываний команда получает SARIF-файл, видит ruleId, сообщение и позицию в исходнике, но всё равно не может решить, что делать. Одни считают result достаточным основанием для блокировки, другие предлагают убрать правило. Проблема в модели данных: файл результата и правило описывают форму наблюдения, а решение требует ещё контекста компонента и явной политики обработки.'),
p('Цена смешения слоёв быстро растёт. Если parser превращает location в доказательство исполнения, создаётся ложная срочность. Если ruleId воспринимается как «шум», правило пропадает для другого участка с иным входом. Если policy скрыта в комментарии к PR, нельзя восстановить владельца, scope и дату пересмотра. Надёжнее разложить сигнал по контракту: формат сохраняет поля, rule задаёт intent, result указывает на совпадение, а человек добавляет проектный контекст и решение.'),
h2('SARIF — транспорт результата, а не модель приложения'),
p('Стандарт SARIF 2.1.0 определяет log с полем <code>version</code> и массивом <code>runs</code>. В run описываются tool, его driver и rules, а results ссылаются на ruleId и locations. Это хороший общий язык между анализатором, CI и просмотрщиком: result не нужно переводить в свободный текст при каждом шаге. Но общность формата имеет границу. SARIF не знает, какой asset важнее, какой feature flag включён, кто подтверждает boundary и какова допустимая политика исключений в этой команде.'),
p('Именно поэтому нужно не «обогащать SARIF догадками», а держать рядом отдельную context record. В нашем synthetic примере это пять строк: asset, entry point, trust boundary, owner и release scope. Их можно привязать к result fingerprint, но нельзя выдавать за поля, полученные из scanner. Такой разрыв делает audit честным: можно увидеть, что пришло из инструмента, что добавил reviewer и где решение всё ещё опирается на неизвестное.'),
table('Граница данных вокруг одного SARIF result', ['Объект', 'Пример поля', 'Кто его создаёт', 'Что это означает', 'Чего не доказывает'], [
['SARIF log', 'version: 2.1.0', 'producer формата', 'версия транспортного контракта', 'качество анализа'],
['Tool driver', 'name, version, rules', 'анализатор', 'какой набор правил описан', 'что правило запускалось в вашем CI'],
['Rule', 'id, level, revision', 'автор policy', 'intent и конфигурацию правила', 'что совпадение опасно именно здесь'],
['Result', 'ruleId, message, fingerprint', 'анализатор', 'одно совпадение с rule', 'выполнимость пути или exploitability'],
['Location', 'URI, startLine', 'анализатор', 'указатель на позицию', 'актуальность ревизии или runtime execution'],
['Context record', 'asset, boundary, owner', 'владелец компонента', 'условия проектного review', 'полное покрытие всех сценариев'],
]),
h2('Правило должно называть свою гипотезу'),
p('Хорошее правило не обязано решать всю задачу безопасности. Ему достаточно точно назвать наблюдаемую гипотезу: «встречена конструкция команды с недоверенным значением». В synthetic contract rule имеет id, revision, category, default level и message. Revision важна: один и тот же id может жить дольше конкретного pattern, а reviewer должен видеть, по какой форме сделано решение. Level задаёт ожидаемую обработку, но не заменяет evidence для одного result.'),
p('Ниже показана учебная форма YAML. Она не лежит в файлах проекта и не выполняется fixture. В мае 2023 Semgrep v1.20.0 уже был опубликован, а исходник CLI с этим тегом версии явно объявлял <code>--sarif</code> как output format. Это позволяет сослаться на конкретную версию инструмента и формат, но не позволяет заявить, что правило ниже было запущено Semgrep, увидело исходник или воспроизводит чью-то production policy.'),
code(syntheticRuleExample),
p('В реальном репозитории rule change надо review как код: зафиксировать revision, diff, языки, scope файлов и ожидаемую причину срабатывания. Нельзя оправдать изменение фразой «снизили шум», если не названо, какую форму теперь намеренно игнорируют. Если rule становится уже, это означает управляемый trade-off: часть будущих совпадений больше не придёт. Такой trade-off допустим только рядом с контекстом и владельцем, а не как побочный эффект неясного refactor YAML.'),
figure('/assets/editorial/2023/static-analysis-2023-rule-context.svg', 'Схема границ данных: правило с версией и intent порождает synthetic SARIF result с location; отдельная context record добавляет asset, entry point, trust boundary, owner и scope; только затем появляется решение.', 'Схема объясняет контракт учебного процесса. Она не показывает настоящий запуск анализатора, фактический исходный код, покрытие, достижимость или вывод о безопасности.'),
h2('Result и location надо читать как указатель'),
p('Result полезен, потому что соединяет message, ruleId и конкретный fingerprint. Fingerprint помогает отличить один результат от соседнего при повторном review. Но fingerprint — это идентификатор сопоставления, а не персональный риск-score. URI и строка тоже не становятся доказательством сами по себе: за время между анализом и review файл мог поменяться, а обработчик мог быть недостижим в интересующем релизе. Поэтому в context record стоит хранить ссылку на ревизию и scope, который проверяется, а рядом писать метод, которым планируется проверить entry point.'),
p('В fixture location намеренно синтетическая: <code>src/demo-command.js:14</code>. Assertions проверяют, что строка сохранилась в объекте и что <code>sourceRead</code> равен <code>not-performed-by-fixture</code>. Это защита от удачной, но ложной демонстрации. Если тест мог бы пройти, одновременно делая вывод «строка прочитана анализатором», он учил бы ровно той подмене, против которой направлена статья.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> У result есть message и location, но reviewer не может объяснить, почему действие должно быть keep, tune или suppress. Не назначайте severity по впечатлению от текста сообщения.',
'<strong>Причина.</strong> Формат обмена, intent правила, совпадение и проектная политика слиты в один JSON или комментарий. Владелец каждого поля исчезает.',
'<strong>Проверка.</strong> Выпишите отдельно version SARIF, tool version, rule id/revision, result fingerprint и location. Затем заведите context record с asset, entry point, boundary, owner и release scope.',
'<strong>Действие.</strong> Свяжите context record с fingerprint, но храните его как проектное evidence. При неполных полях верните статус <code>context-incomplete</code>, а не подменяйте отсутствующие данные предположением.',
'<strong>Проверка контракта.</strong> Запустите fixture. Он должен принять только <code>synthetic-static-analysis-input-v1</code>, отвергнуть SARIF другой версии и не добавить claims о runtime.',
'<strong>Rollback.</strong> Если обсуждение меняет policy, в плане остаётся before state. Реальный rollback требует отдельного config diff и повторного анализа; synthetic output этого не исполняет.',
]),
h2('Контекст не заменяет техническую проверку'),
p('Context record делает вопрос обозримым, но не превращается в доказательство. Поле <code>entryPoint: demo-http-handler</code> — гипотеза о месте входа. Поле <code>trustBoundary: demo-request-parameter</code> — договорённость, что именно считают недоверенным в этом сценарии. Для реального решения нужно выбрать метод проверки: review data flow, targeted test, trace в разрешённом окружении или другой подход, который команда может повторить. Метод и его границы следует записывать рядом, иначе на следующем review неизвестно, что именно было проверено.'),
p('NIST SSDF полезен здесь не как набор волшебных правил, а как напоминание о воспроизводимости безопасной разработки: изменение должно иметь владельца, evidence и путь повторной проверки. Если результат анализатора не совпадает с контекстом компонента, не надо заставлять один из слоёв притворяться другим. Лучше оставить запись «формат и rule известны; контекст или техническая проверка ещё не завершены». Это менее эффектно, чем слово finding, но точнее определяет следующий шаг.'),
h2('Ограничения модели и следующий шаг'),
p('Этот материал не сравнивает анализаторы, не измеряет false positive rate, не утверждает поддержку всех языков и не обещает, что SARIF одинаково заполнен каждым producer. Спецификация SARIF 2.1.0 имеет статус OASIS Standard от 27 марта 2020 года; Semgrep v1.20.0 — выбранная историческая точка от 28 апреля 2023 года. Их наличие не заменяет проверку реальной версии CLI, rule packs, schema validation, base commit и настроек CI конкретного проекта.'),
p('Следующий шаг — выбрать один существующий result и собрать два маленьких артефакта: неизменяемую выдержку из SARIF без секретов и context record с пятью полями. Затем на review сформулировать одну гипотезу rule и метод её проверки. Если context не собирается, не переписывайте rule наугад. Сначала назначьте owner и срок. Так формат остаётся транспортом фактов, а решение — проверяемой частью инженерной работы.'),
], [sources.sarif, sources.sarifSchema, sources.semgrepRelease, sources.semgrepSarif, sources.ssdf]);
const field = revision({
slug: 'editorial-2023-05-field-static-analysis',
title: 'Отключение правила: как сделать явное решение, ограничение и rollback',
categories: ['Безопасность', 'Процессы'],
cover: '/assets/editorial/2023/static-analysis-2023-decision-gate.svg',
excerpt: 'Политика для шумного правила: оставить, уточнить или временно подавить один результат — с владельцем, scope, датой пересмотра и обратимым планом.',
readingMinutes: 13,
}, [
p('Когда статическое правило мешает выпуску, решение «выключить его» кажется самым коротким. В нём нет owner, scope, причины, срока и способа вернуться назад. Проблема не в том, что любое исключение плохо. Проблема в том, что глобальное отключение меняет обработку будущих сигналов, а команда обычно обсуждает один конкретный результат в одном изменении.'),
p('Цена неявного выключения двойная. Опасная категория — построение команды из недоверенного значения — исчезает из наблюдаемой поверхности даже там, где контекст другого компонента ещё не изучен. Одновременно исчезает след решения: через месяц невозможно понять, почему правило стало тише и кто проверял границу. Альтернатива не требует тяжёлого процесса: три разрешённых действия, небольшой evidence record и rollback, который возвращает предыдущую видимость правила, а не обещает отменить уже выпущенный код.'),
h2('Три решения вместо одной кнопки'),
p('Первое решение — <code>keep</code>: result остаётся видимым, пока владелец проверяет контекст. Оно подходит, когда есть signal, но нет основания менять rule или исключать результат. Второе — <code>tune</code>: команда меняет правило, потому что обнаружила слишком широкую или неверную гипотезу. Такое изменение обязано иметь новую rule revision, ясный diff и review того, какие формы теперь перестанут совпадать. Третье — <code>suppress</code>: временно ограничивается обработка одного идентифицируемого результата, но само правило сохраняется.'),
p('Глобальное <code>disable</code> сознательно не входит в этот набор для учебной модели. Это не универсальный запрет на все проекты: иногда policy меняется широко и осознанно. Но тогда это уже отдельное решение уровня правила или набора правил — с владельцем policy, ожидаемым влиянием и самостоятельным review. Нельзя прятать такую смену под исключение одного fingerprint. В нашем fixture action <code>disable-globally</code> отвергается, чтобы API не поощрял неверный уровень действия.'),
table('Контракт явного решения', ['Action', 'Минимальный evidence', 'Область действия', 'Что остаётся неизвестным', 'Rollback'], [
['keep', 'owner, reason, reviewBy', 'result остаётся видимым', 'достижимость и итог security review', 'ничего не применяется; повторить triage'],
['tune', 'owner, reason, rule revision, diff summary', 'будущие совпадения изменённой гипотезы', 'эффект diff на реальном проекте', 'вернуть прежнюю rule revision отдельным diff'],
['suppress', 'owner, reason, fingerprint, scope, expiresOn', 'один конкретный result', 'безопасность и влияние подавления', 'убрать scoped exception и повторить review'],
['disable globally', 'не является исключением одного result', 'все текущие и будущие результаты', 'масштаб потери сигнала без отдельной оценки', 'требует отдельной policy-процедуры'],
]),
h2('Evidence record должен быть коротким и достаточным'),
p('Для решения хватает пяти обязательных полей: owner, reason, reviewBy, action и связь с точным объектом. Для tune этим объектом служат новая rule revision и описание изменения; повторить текущую revision под видом tune нельзя. Для suppress — result fingerprint, scope <code>exact-synthetic-fingerprint</code> и expiresOn. Поле reason не должно быть «шум»: оно называет проверенную границу, например «synthetic exception демонстрирует ограниченное подавление». В настоящем проекте reason должен ссылаться на воспроизводимый context или задачу проверки, а не на авторитет автора комментария.'),
p('Дата пересмотра не делает исключение безопасным автоматически. Она лишь создаёт точку, в которой решение снова будет видно. Обе даты нужны в формате календарного дня, а <code>reviewBy</code> не может быть позже <code>expiresOn</code>: иначе обязательная проверка придёт уже после снятия исключения. Если к reviewBy не собраны данные, честный результат — продлить review с объяснением или вернуть result в обычный поток. Нельзя молча продлевать suppress в каждом PR: срок полезен только вместе с владельцем и входным evidence. Важно также различать <code>reviewBy</code>, то есть дату следующей проверки решения, и <code>expiresOn</code>, то есть предел действия scoped exception.'),
h2('Учебный fixture: план решения без изменения конфигурации'),
p('Команда ниже строит synthetic SARIF result, добавляет synthetic context и планирует keep, tune или suppress. Она не меняет файл конфигурации, не загружает rule pack, не открывает pull request и не переключает CI. План содержит поле <code>applied: not-applied-by-fixture</code>; rollback возвращает только <code>visible-in-synthetic-plan</code>. Этим пример полезен для проверки полей, но его нельзя использовать как журнал фактического выпуска или как доказательство, что исключение было применено.'),
code(fixtureCommand),
p('Fixture проверяет несколько отрицательных случаев. Action <code>disable-globally</code> отклоняется. Suppress без точного synthetic fingerprint, scope и expiresOn отклоняется; календарная дата вроде <code>2023-02-30</code> и review после expiry тоже не проходят. Tune без revision и change summary либо с неизменённой revision отклоняется. Отдельная проверка удаляет trust boundary из context и получает <code>context-incomplete</code>. Это не тест реального правила: он детерминированно проверяет, что сама процедура не стирает нужные границы и не может назвать outcome безопасным.'),
figure('/assets/editorial/2023/static-analysis-2023-decision-gate.svg', 'Гейт решения для synthetic результата: после полного context выбираются keep, tune или suppress; suppress требует точный fingerprint и expiry; глобальное отключение выведено за пределы обычного решения; rollback возвращает прежнюю видимость учебного плана.', 'Схема отображает policy-контракт, а не конфигурацию CI или факт применения исключения. Она не содержит реальные findings, данные репозитория, метрики или сведения о безопасности выпуска.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Правило хотят убрать после одного или нескольких неудобных results. Остановите обсуждение на уровне конкретного ruleId и fingerprint, не на уровне общего раздражения.',
'<strong>Причина.</strong> У исключения нет scope и срока, а policy всего правила смешана с обработкой одного участка. Будущие результаты получают непреднамеренно другой режим.',
'<strong>Проверка.</strong> Сверьте rule revision, context record, owner и точный result. Для tune прочитайте diff и проверьте, что revision новая; для suppress убедитесь, что fingerprint относится к одному объекту, даты календарные, а reviewBy не позже expiry.',
'<strong>Действие.</strong> Выберите keep, tune или scoped suppress. Запишите evidence в review и сформулируйте следующий технический шаг: проверить flow, обновить rule или снять исключение до срока.',
'<strong>Проверка контракта.</strong> Выполните fixture. PASS должен показать, что global disable и unscoped suppress отклонены, а rollback ничего не применяет к scanner config.',
'<strong>Rollback.</strong> Для реального изменения приготовьте обратный config diff, критерий запуска повторного анализа и owner. Не называйте rollback успешным, пока эти действия не выполнены в границах проекта.',
]),
h2('Tune — это изменение гипотезы, а не фильтр шума'),
p('У rule есть цена сопровождения: чем точнее гипотеза, тем больше контекста она требует; чем шире, тем больше результатов отправляет в review. Tune отвечает на вопрос, какую форму команда хочет искать после изменения. Поэтому change summary должен быть техническим: «требуется явный marker boundary» или «исключён generated path при условии X». Формулировка «сделать правило тише» скрывает условие и не позволяет reviewer оценить trade-off.'),
p('После tune не стоит заявлять, что ложных результатов стало меньше или опасные случаи не потеряны, пока этого не доказали конкретной проверкой. Достаточно честного статуса: rule revision изменена, ожидаемая граница названа, реальный effect ещё надо посмотреть на выбранном наборе. Если такого набора нет, решение должно предусматривать следующий review, а не статистику из памяти. M6-голос здесь важнее уверенности: он удерживает связь между policy diff, evidence и ограничением метода.'),
h2('Scoped suppress должен уметь исчезнуть'),
p('Временное suppress допустимо только на самом узком идентификаторе, который проект может проверить повторно. В fixture это synthetic fingerprint; в настоящем инструменте формат идентификатора и механизм подавления надо уточнять по версии и документации анализатора. Scope нужен, чтобы исключение не захватило соседние results, похожие по тексту. Expiry нужен, чтобы обсуждение вернулось в очередь. Owner нужен, чтобы задача не стала бесхозной. Ни одно из этих полей не делает сигнал ложным или безопасным; они ограничивают только политику обработки.'),
p('Rollback не равен удалению комментария. Для scoped exception rollback — удалить или изменить точное исключение, снова запустить согласованный анализ и посмотреть, какой result вернулся. Для tune rollback — вернуть предыдущую rule revision и проверить diff правила. Для широкой смены policy нужен отдельный план, потому что её blast radius выходит за один fingerprint. Учебная функция <code>rollbackSyntheticRuleDecision()</code> специально возвращает <code>configEffect: not-applied-by-fixture</code>, чтобы не скрывать этот разрыв между планом и применением.'),
h2('Ограничения модели и следующий шаг'),
p('Модель не знает формат подавления вашего анализатора, доступы к CI, baseline, code ownership, регуляторные требования, язык кода, сроки релиза и фактическое число результатов. Она не заявляет, что Semgrep v1.20.0 или любой другой producer создаёт указанные fingerprints именно так. Исторические ссылки фиксируют только SARIF 2.1.0 как OASIS Standard от 27 марта 2020 года и наличие <code>--sarif</code> в исходнике Semgrep v1.20.0, опубликованного 28 апреля 2023 года.'),
p('Следующий шаг — оформить одну реальную decision record с минимальными полями и без секретных фрагментов кода. Если выбран keep, назначить метод проверки context. Если tune, подготовить небольшой rule diff и review его границы. Если suppress, указать самый узкий scope и дату возвращения. Если разговор всё-таки про global policy, вынести его из PR в отдельное решение с владельцем и ожидаемым воздействием. Так команда не притворяется, что noise исчез сам, и не превращает удобный выключатель в необратимую потерю сигнала.'),
], [sources.sarif, sources.sarifSchema, sources.semgrepRelease, sources.semgrepSarif, sources.ssdf]);
export const revisions = Object.freeze([practice, mechanism, field]);
function printFixtureResult() {
const fixture = runStaticAnalysisFixture();
const failed = Object.entries(fixture.assertions).filter(([, passed]) => !passed).map(([name]) => name);
if (failed.length > 0) {
console.error('FAIL fixture: ' + failed.join(', '));
process.exitCode = 1;
return;
}
console.log('PASS fixture: ' + Object.keys(fixture.assertions).length + '/' + Object.keys(fixture.assertions).length + ' assertions');
}
if (process.argv.includes('--verify-fixture')) {
printFixtureResult();
} else if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
}