SARIF report
--sarif <path> writes a SARIF 2.1.0 log — the standard format GitHub Code Scanning (and other SARIF-consuming dashboards) expect — instead of, or alongside, --json's raw engine result.
$ surea11y scan ./dist/index.html --sarif results.sarif
See CI integration for a ready-to-paste GitHub Actions workflow that runs a scan and uploads results.sarif to the "Security" tab.
Why a separate format from --json
--json's raw result (see Output schema) is this engine's own contract, versioned and stable per API_STABILITY.md. SARIF is a different, externally-defined contract purpose-built for code-scanning dashboards — a checksResults[] entry and a SARIF result don't map 1:1, so this is a real conversion, not a re-serialization.
What becomes a SARIF result
Only fail/cantTell occurrences produce SARIF results (same "violations only" framing as the HTML report). So a log with no results says nothing failed or needs review among the checks that ran; it doesn't say what was checked, and it is not a conformance claim. Keep the JSON result for that.
A notApplicable check is not always empty: a pack's or custom rule may attach one occurrence explaining why it had nothing to judge (core's rules attach none, since 1.16.0). Those never become results — a consumer treats every result as an alert, and "this was not evaluated" is not one — but they are not dropped either. They are carried as note-level entries in runs[0].invocations[0].toolExecutionNotifications, each naming the rule it came from via associatedRule.id:
"invocations": [{"executionSuccessful": true,"toolExecutionNotifications": [{"level": "note","message": { "text": "No form on this page could be checked: each is inside a closed shadow root." },"associatedRule": { "id": "acme-form-review" }}]}]
That keeps a SARIF-only pipeline from reading silence as a clean bill of health. Core's contrast rules need no notice for it: text whose contrast could not be computed is a contrast-computable result. The block is emitted only when there is something to say, so a run with nothing to report has no invocations key at all. A rule that passed writes no notification.
A rule that did not complete (it threw: cantTell with no occurrences and an error) judged nothing, so it is no result either. It is an error-level notification in the same list, "The rule <id> did not complete: <error>", naming the rule in associatedRule.id, so the gap in coverage doesn't read as a pass.
| Engine outcome | SARIF level | Meaning |
|---|---|---|
fail | error | Deterministic violation — the CI-gating case. |
cantTell | warning | Needs human review — surfaced, but shouldn't block a build on its own. |
cantTell leaning fail, with leanFailAs: 'error' | error | Undecided, but the page's evidence points to a failure (lean); counted by a gate on errors. |
An undecided finding with a lean carries it in properties.lean: { "toward": "fail", "why": "The heading skips a level after the one before it." }, whichever level it is reported at. Where the lean has them, it also carries caution, what could make it wrong ("A layout table can also hold numbers."), and measured, how often it has been right ({ "agreed": 47, "cases": 50, "corpus": "…", "coreVersion": "…" }, once a lean has been counted on five cases, which none has yet), so an alert shows what the HTML report shows beside the lean. Its signals and i18n keys stay in the scan result.
A rule whose every WCAG criterion is outside the scan's target (an AAA rule under engineOptions.wcagLevel: 'AA', or duplicate-id under WCAG 2.2: the criteria the scan left out of rulesResults, as for the wcag-<SC> tags below) still ran, and its fail and cantTell findings are still results, at level note, so they are seen without failing a build scoped to the target. Each says why in properties.outsideTarget: { "criteria": ["1.4.6"], "target": "WCAG 2.2 AA" }.
Every rule that ran (regardless of whether it produced a result) is listed once in runs[0].tool.driver.rules, with defaultConfiguration.level set from the rule's type: automatic (fail-capable) → error, manual (never fail) → warning. A rule id that is not a string, in a stored result, is written as its text; a check with no rule id names no rule, and is left out, as JUnit and EARL leave it.
Field mapping
| SARIF field | Source |
|---|---|
results[].ruleId | checksResults[i].ruleId |
results[].message.text | occurrence.summary + occurrence.hint. An occurrence with no summary (a custom rule's, say) uses the rule's title, or its id, since GitHub rejects a result with empty text. An undecided finding's steps follow, after a blank line: How to check:, the numbered steps, then Passes when: and Fails when:, so an alert says how to answer it. |
results[].properties.howToCheck | An undecided finding's steps, { steps, passWhen, failWhen }, as occurrence.howToCheck gives them. A result that comes without them, as a binding's does, gains them in Node; packs (the packs the scan ran) adds a pack rule's own. Like the lean, not part of the finding's identity. |
results[].locations[].physicalLocation.artifactLocation.uri | The scanned target — see Locations below. |
results[].locations[].logicalLocations[].fullyQualifiedName | The element's location, with kind: "element", when occurrence.selector is a string that isn't empty. Outside a shadow tree it is the selector. For an element inside a shadow tree it is the shadow hosts' selectors, then the element's own, joined with >>> : html > body > main > x-card >>> button. JUnit's selector: line and the HTML report show the same text. |
results[].partialFingerprints["surea11y/violation/v2"] | The SHA-256 digest, 64 hex digits, of the same ruleId + reasonCode + html identity key used by Baseline / allowlist (computeBaselineKey) — a stable, content-based fingerprint rather than a position-based one, for consumers that read it: the same finding gets the same digest in every report. Before 1.11, surea11y/violation/v1 held the key itself, so a tool that read v1 sees each finding as new once. A finding whose reason code changes gets another digest: in 1.14, the findings of the seventeen rules that now give a code, and those of the codes split by cause, so their Code Scanning alerts close once and the findings come back as new (see Baseline / allowlist). |
results[].partialFingerprints.primaryLocationLineHash | The digest's first 16 hex digits and a count, <16 hex digits>:<n> (unchanged from before v2), where n counts a finding repeated on the page (the same broken component twice). GitHub Code Scanning matches alerts between uploads on this field alone. A DOM finding has no line for GitHub's upload action to hash, so without it alerts were matched by position, and fixing one finding could close another's alert. Coming from core 1.8 or earlier, the first upload closes the existing alerts and opens them again as new ones, once; a dismissal made in GitHub has to be made again. |
results[].properties.severity / .confidence / .reasonCode / .html | The result's own severity/confidence, plus the same reasonCode and html snippet the fingerprint above is keyed on — informational, not part of SARIF's own schema. |
tool.driver.rules[].properties.tags | accessibility, automatic/manual,and a wcag-<SC> tag per WCAG Success Criterion in meta.normativeMappings that the scan's WCAG level and version keep. A criterion the scan left out of rulesResults because of them gets no tag: an AAA criterion under engineOptions.wcagLevel: 'AA', or 4.1.1 when includeRuleIds: ['duplicate-id'] ran under WCAG 2.2. A criterion the scan reports, such as a rollup named by id above the level, keeps its tag, and so does every criterion of a result that records neither engine.wcagLevel nor engine.wcagVersion. The rule stays, and its results too, as notes when every criterion it maps to is left out (see above). Understanding-document entries get no tag. Each EN 301 549 clause the result carries (only when the scan asked for them, see Other standards) gets an en301549-<clause> tag, e.g. en301549-9.1.1.1: clause numbers are the same in every version that has them, so the tag carries no version, and a pack's standard or checklist is tagged the same way under its own key (a checklist item's id, acme-images, is its own tag). Then the rule's own meta.tags (a custom rule's included), without the engine's bookkeeping tags and the criterion tags the wcag-<SC> tags already give. |
tool.driver.rules[].helpUri | meta.helpUrl, the rule's help link, when it is an absolute http(s) URL; otherwise the Understanding document of the first WCAG criterion it maps to; absent for a rule with neither, which no built-in rule is: one mapped to no criterion links its section of the rule catalog. |
tool.driver.version | The toolVersion option, if given (a tool wrapping the engine passes its own); otherwise engine.version, the engine release that produced the result."0.0.0" only for a result without one, from a release before 1.10.0, or whose engine.version is not a string. |
runs[0].automationDetails.id | The category option, with a trailing / added if it has none (GitHub reads everything up to the last slash as the category). Absent when no category is given. See Scanning at several viewport widths. |
runs[0].properties | wcagVersion, wcagLevel (when the scan had a level: engineOptions.wcagLevel, or the one its selection gives), profile (the first profile, for a scan of several), profiles (a scan of several profiles only: every profile it ran, engine.profiles), optInRules and environment (a copy of engine.environment) from the result's engine: the conformance target the run used, so a dashboard can tell a WCAG 2.1 run from a 2.2 one, and an AA run from one at every level, the opt-in rule tags it added beyond that target when it added any, and the conditions the page was rendered under (layout, viewport, devicePixelRatio, colorScheme, forcedColors, fonts and every other field it has, the hooks' included; see Output schema). Omitted for results from releases that predate those fields. |
A compact result (engineOptions.output.detail: 'findings', see Output schema) is read as the full result it stands for, so it is reported the same. A result whose standards list is missing or damaged still tags every standard its rules name.
Locations
DOM-based scanning has no line/column to report, so physicalLocation.artifactLocation.uri is the scanned target itself, not a source-file position:
- Local file scans: a file inside the current working directory is a path relative to it, forward-slashed, with each segment percent-encoded (
build%20out/index.html). If this matches a real file in your repository, GitHub Code Scanning can render the finding as an inline annotation. A file outside the working directory is an absolutefile:URL, not a relative path climbing out of the repository with../, which a consumer could not resolve. - URL scans: the scanned URL itself. GitHub Code Scanning will still list the finding, but can't attach an inline annotation to a URL that isn't a file in the repository — this is inherent to how SARIF/Code Scanning associate findings with source, not a surea11y limitation. If you need inline annotations, scan the rendered HTML file (e.g. a build output artifact) rather than a live URL. The URL is written as a URL parser serializes it, so what a URI can't hold is percent-encoded (
https://example.test/a%20b). - Other targets: a result whose
urlis not a URL, such as the path a page was scanned under (runDomRulesInPage('dist/my page.html', …)), is a relative path, forward-slashed, with each segment percent-encoded (dist/my%20page.html); a Windows path is an absolutefile:URL. A result with nourl, or one that is not a string, is located atabout:blank.
The element's location (its selector, prefixed with its shadow hosts' selectors and >>> when it is inside a shadow tree) is additionally carried as a logicalLocations[].fullyQualifiedName, so a consumer that reads logical locations still gets the "which element" signal even without a usable physical location.
Cross-frame results
A result from runa11yCoreAcrossFrames, or a binding's .frames(true) scan (see Output schema), is one run: every frame's findings are results in it. A finding in a frame is located in that frame's document (artifactLocation.uri is the frame's URL), and carries the frame's path in properties.frame:
"properties": {"severity": "serious","confidence": "high","reasonCode": "DEFAULT","html": "<img src=\"card.png\">","frame": ["#checkout"]}
The path is part of the finding's identity too (partialFingerprints), so the same defect in the page and in a frame, or in two frames, are two alerts. A frame that did not answer (no responder, or it timed out) is a warning-level notification, "The frame #ads (https://ads.example/) was not scanned: <error>", since nothing in it was checked.
Scanning at several viewport widths
A layout-dependent rule can fail at one viewport width and pass at another (see Known limitations), so a page scanned at two widths is two analyses, not one. Give each its own category:
const { renderSarifReport } = require('@surea11y/core/sarif');for (const width of [390, 1280]) {// ...scan the page at this width into `result`...fs.writeFileSync(`a11y-${width}.sarif`, renderSarifReport(result, { category: `a11y-${width}` }));}
and upload each file separately. GitHub Code Scanning treats each category as its own analysis. Within one category, a later upload replaces the earlier one, and an alert missing from it is closed, so two widths uploaded under the same category would open and close a width-specific alert on every run. GitHub also rejects a single SARIF file holding two runs from the same tool and category. github/codeql-action/upload-sarif has a category input that does the same job when the file carries none.
The finding's identity doesn't include the viewport, and doesn't need to: the same element with the same defect is the same finding at any width, and the category is what keeps the analyses apart. runs[0].properties.environment records the viewport each run used.
Options
renderSarifReport(result, options) from @surea11y/core/sarif returns the log as JSON text. It takes:
| Option | Default | Effect |
|---|---|---|
toolVersion | engine.version | tool.driver.version, for a tool wrapping the engine. |
informationUri | 'https://github.com/SureA11y/core' | tool.driver.informationUri, the tool's home page. |
baselineEntries | none | The baseline file as saved (JSON.parse of it) or its entries list, see File format. Fail occurrences recorded there are left out, as --baseline does (see below). An entry written by the minor release before, under a reason code that has changed since, still matches the finding under its new one (see Upgrading). |
allowProfilesNotRun | false | A scan of several profiles that ran fewer than it asked for (engine.profilesNotRun) counted less than it was asked to: each profile not run is an error notification, and the invocation is not executionSuccessful. true makes them warnings. A value other than true or false throws a TypeError. Core 1.15.0 and later. |
category | none | runs[0].automationDetails.id, see Scanning at several viewport widths. |
leanFailAs | 'warning' | 'error' reports a cantTell finding whose lean points to fail at error, so a build gated on errors fails on likely failures too. Read in any case; another value throws a TypeError. A best-practice rule's too: such a rule, mapped to no WCAG criterion, never fails, and the facts it states lean fail without condition, so a skipped heading level (heading-order) is an error. A finding of a rule whose every criterion is outside the scan's target stays a note. A finding that leans fail and has an entry marked lean: 'fail' in baselineEntries stays a warning. |
leanFailWcagOnly | false | With leanFailAs: 'error', true raises only the findings of rules mapped to a WCAG criterion in the scan's target, leaving the best-practice rules' leans at warning. A value other than true or false throws a TypeError. leaningFailures(result, { wcagOnly }), from @surea11y/core, lists the findings these two options raise, before the baseline. |
packs | none | The packs the scan ran (engineOptions.packs): in Node, a pack rule's undecided findings carry the steps of its own meta.review (properties.howToCheck). |
Combining with --baseline
A generic SARIF consumer has no "known, don't gate on this" concept of its own — the only faithful way to honor a baseline in SARIF output is to omit already-known fail occurrences entirely, rather than downgrade them to warning:
$ surea11y scan ./dist/index.html --baseline baseline.json --sarif results.sarif
cantTell occurrences are never left out by a baseline. A baseline written with buildBaselineEntries(result, { leanFail: true }) also records the findings that lean fail, as entries marked lean: 'fail' (see Baseline / allowlist); under leanFailAs: 'error' such a finding stays a warning instead of being raised to error, so a team can accept the likely failures it already has and still gate on new ones. --write-baseline records fail occurrences only.
Combining with --html/--json
--sarif, --html, and --json are independent output flags — pass any combination in one run; each writes/prints its own report from the same single scan.