Skip to content

tamp-build/tamp-axe-core

Tamp.AxeCore

Tamp CommandPlan wrappers for standalone web accessibility scanning — @axe-core/cli for the scan itself and axe-sarif-converter for the JSON → SARIF emission that feeds the Tamp security pipeline.

Package Status
Tamp.AxeCore 0.1.0 (initial)

Install

dotnet add package Tamp.AxeCore

Multi-targets net8 / net9 / net10. The wrapper is .NET; it shells out to two npm-distributed CLIs (axe and axe-sarif-converter) that live in your project's node_modules (or a global install). See Binary resolution below for the resolution order the wrapper uses.

When to use this — and when NOT to

Use Tamp.AxeCore when you want to scan a deployed URL for accessibility violations from your build pipeline — typical for SaaS / public-facing apps where the security target probes a staging / preview environment after deploy.

Don't use Tamp.AxeCore if you already have Playwright tests. @axe-core/playwright is a JS-side library that runs AxeBuilder({ page }).analyze() from inside your existing test files — install via Tamp.Npm and the findings flow through your test results. No .NET wrapper layer needed (and no separate SARIF conversion step).

Why two verbs

Unlike ESLint or Trivy, @axe-core/cli does not emit SARIF natively — it produces axe's own JSON shape. The Tamp security chain consumes SARIF, so a separate conversion step is needed. Tamp.AxeCore ships both verbs:

  • AxeCore.Scan(...) — wraps @axe-core/cli, produces raw axe JSON.
  • AxeCore.ConvertToSarif(...) — wraps axe-sarif-converter, converts that JSON to SARIF 2.1.0.

Both axe and axe-sarif-converter are separate npm packages — install both in your project's devDependencies:

pnpm add -D @axe-core/cli axe-sarif-converter
# or
npm install -D @axe-core/cli axe-sarif-converter

Quick start — skippable security-pipeline target

using Tamp;
using Tamp.AxeCore;

[Parameter] readonly string StagingUrl = "https://staging.example.com";
AbsolutePath WebRoot => RootDirectory / "web";
AbsolutePath SecurityArtifacts => RootDirectory / "artifacts" / "security";

Target SecurityScanAxeCore => _ => _
    .Description("Standalone a11y scan against the deployed staging URL.")
    .Executes(() =>
    {
        if (!AxeCoreBinaryResolver.IsAvailable(WebRoot))
        {
            Log.Info("[security] AxeCore skipped — toolchain not installed at {Dir}", WebRoot);
            return;
        }

        SecurityArtifacts.CreateDirectory();
        var json  = SecurityArtifacts / "axe.json";
        var sarif = SecurityArtifacts / "axe.sarif";

        var scanPlan = AxeCore.Scan(s => s
            .SetWorkingDirectory(WebRoot)
            .AddUrl(StagingUrl)
            .SetOutputFile(json)
            .AddTag("wcag2a").AddTag("wcag2aa").AddTag("wcag21aa").AddTag("best-practice")
            .SetBrowser("chromium")
            .SetNoSandbox()          // required for Docker/CI containers
            .SetTimeoutSeconds(60)
            .SetLoadDelayMs(2000));

        var scanExit = ProcessRunner.Execute(scanPlan);
        // axe exits 1 on violations when --exit is set; treat 0 and 1 as successful scan.
        if (scanExit > 1) throw new InvalidOperationException($"axe-core failed with exit {scanExit}.");

        var sarifPlan = AxeCore.ConvertToSarif(s => s
            .SetWorkingDirectory(WebRoot)
            .SetInputFile(json)
            .SetOutputFile(sarif));
        ProcessRunner.Execute(sarifPlan);

        // 0.1.1+ — stamp `properties.tags: ["accessibility"]` on every result so the
        // downstream SARIF consumer can route by category without scanner-name heuristics.
        AxeCoreSarif.AnnotateResults(sarif, "accessibility");

        // sarif is now ready for /ingest/findings (tamp-ingest-v1).
    });

Verb surface (v1)

AxeCore.Scanaxe <urls...> --save <json> [flags]

Setting CLI flag Required
AddUrl(url) positional (repeatable) ≥1
SetOutputFile(json) --save <path> yes
AddRule(id) (repeatable) --rules <id,id,...> no
AddTag(tag) (repeatable) --tags <tag,tag,...> no
AddInclude(selector) --include <selector> (repeatable) no
AddExclude(selector) --exclude <selector> (repeatable) no
SetBrowser(name) --browser <name> no
SetChromiumBinary(path) --chromium-binary <path> no
SetExit(true) (default) --exit
SetTimeoutSeconds(n) --timeout <n> no
SetLoadDelayMs(ms) --load-delay <ms> no
SetNoSandbox(true) --no-sandbox no
SetNoReporter(true) --no-reporter no

Common WCAG tags: wcag2a, wcag2aa, wcag21aa, wcag22aa, best-practice. Combine to scope the rule set.

AxeCore.ConvertToSarifaxe-sarif-converter <input.json> <output.sarif>

Setting Position Required
SetInputFile(json) positional 1 yes
SetOutputFile(sarif) positional 2 yes

Pure conversion — no flags beyond the positional input/output. The output SARIF 2.1.0 plugs straight into the tamp-ingest-v1 /ingest/findings contract.

Source-side category tagging (0.1.1+)

@axe-core/cli + axe-sarif-converter produce SARIF 2.1.0 but don't stamp a category property on each result. Downstream sinks consuming SARIF from multiple scanners end up doing scanner-name string-matching to route findings into severity / sub-category buckets. AxeCoreSarif.AnnotateResults injects properties.tags on every result after conversion so consumers can route by tag instead — matches what Trivy emits natively.

AxeCoreSarif.AnnotateResults(sarifPath, "accessibility");
// every runs[*].results[*].properties.tags now includes "accessibility"
  • Idempotent. Re-running with the same tag is a no-op; existing tags preserved; case-sensitive dedup per SARIF spec.
  • Preserves other properties untouched (severity, custom fields, etc.).
  • Defensive against malformed input — if properties.tags exists but isn't a JSON array, the annotator replaces it with a fresh array rather than throwing.
  • No new dependency surface — uses System.Text.Json.Nodes.JsonNode from the BCL.
  • Throws ArgumentNullException / FileNotFoundException / InvalidDataException on bad input.

Typical tags: "accessibility" (matches the tamp-ingest-v1 SubCategory vocabulary), "wcag2aa", "axe-core" (scanner name), or domain-specific labels you want downstream sinks to route by.

Binary resolution

Neither axe nor axe-sarif-converter are .NET tools — both ship as npm packages. The wrapper resolves them at Scan(...) / ConvertToSarif(...) time in priority order:

  1. Project-local{WorkingDirectory}/node_modules/.bin/<binary> (or <binary>.cmd on Windows). Preferred because the project's exact pinned versions run.
  2. pnpm execpnpm exec <binary> -- .... Used when pnpm-lock.yaml or pnpm-workspace.yaml is present AND pnpm is on PATH.
  3. npm execnpm exec <binary> -- .... Used when package-lock.json is present AND npm is on PATH.
  4. Global<binary> on PATH.
  5. Not foundToCommandPlan() throws InvalidOperationException with an actionable message.

Pre-flighting (skip when not installed)

AxeCoreBinaryResolver.IsAvailable(workingDirectory) returns true only when both axe and axe-sarif-converter resolve. Use it to skip the security-pipeline target cleanly when the a11y toolchain isn't installed:

if (!AxeCoreBinaryResolver.IsAvailable(WebRoot)) return;

For finer-grained checks, call TryResolveAxe(...) / TryResolveSarifConverter(...) directly.

Overriding resolution explicitly

var binary = new AxeCoreBinaryResolution
{
    Executable = "/opt/ci/axe/4.10.0/axe",
    Source = AxeCoreResolutionSource.Explicit,
};

var plan = AxeCore.Scan(s => s
    .SetBinary(binary)
    .AddUrl("https://staging.example.com")
    .SetOutputFile("axe.json"));

Exit-code semantics

@axe-core/cli follows the linter convention; mirrors Tamp.OpenGrep / Tamp.Eslint.V9:

Exit Meaning Treat as
0 Clean run, no findings success
1 Violations reported (when --exit is set, which is the default) success (findings are still a successful scan)
2+ Tool / config / browser error failure

A security-pipeline target should only throw on exit > 1.

Browser runtime

@axe-core/cli spawns a headless browser (Chromium by default). In CI containers, you'll typically need:

# Either pre-install Chromium via Playwright (recommended):
npx playwright install chromium

# Or rely on @axe-core/cli's puppeteer-bundled Chromium and pass --no-sandbox
# (set via SetNoSandbox(true) in the wrapper).

If you have a specific Chromium binary path (e.g. system-installed via the runner image), pass it via SetChromiumBinary(path).

Settings authoring — fluent or object-init

Both styles produce identical CommandPlans; fluent is canonical in docs.

License

MIT — see LICENSE.

About

Tamp wrappers for standalone web accessibility scanning — @axe-core/cli for the scan, axe-sarif-converter for SARIF emission feeding the Tamp security pipeline. A Tamp satellite.

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages