Skill v1.0.0
currentTrusted Publisher100/100version: "1.0.0" name: emit-sarif description: Serialize AI-detected security findings as SARIF v2.1.0 conforming to the AI-generated-findings profile, using the Sarif.Multitool emit verbs. metadata: author: sarif-sdk-maintainers version: "1.0.0" category: security packages:
- "Sarif.Multitool >= 5.0.3"
triggers:
- "emit SARIF"
- "write findings to SARIF"
- "produce SARIF log"
- "ai/origin"
Emit SARIF Findings
Context
You have completed a security analysis of a codebase and hold one or more findings in working memory. This skill serializes those findings as a SARIF v2.1.0 log that downstream tooling — result-management systems, triage agents, and autonomous remediation agents — can consume without tool-specific knowledge.
The output contract is the AI-generated-findings profile defined in `docs/ai/generating-sarif.md`. That document is the normative reference; this skill is the operational wrapper that uses the Sarif.Multitool emit verbs to produce a conformant log.
When to apply this skill
Apply this skill when an agent is the originating detector (not post-processing another tool's SARIF) and needs to persist findings. Signals:
- The agent has enumerated vulnerabilities with file/line locations and CWE classifications.
- A downstream step expects a
.sarifartifact. - The orchestrator requests
ai/origin: "generated"output (orannotated/synthesized).
Prerequisites
- `Sarif.Multitool` ≥ 5.0.3. Recommended invocation:
dotnet dnx Sarif.Multitool --yes -- <verb> ...(zero-install, version-resolved at first run; requires .NET 10+). Fall back to a global install withdotnet tool install --global Sarif.Multitoolifdotnet dnxis unavailable. - The current commit SHA, branch, repository URI, and a local source-root path.
- The normative profile doc: `docs/ai/generating-sarif.md`. Cross-reference it for every property you populate — do not invent vocabulary.
Method
The skill uses these multitool verbs: emit-run → emit-results / emit-invocations (per finding or scan phase) → emit-notification-descriptors / emit-rule-descriptors (optional descriptor catalogs) → emit-finalize --validate. Each verb either appends to an event log (<output>.wip.jsonl) or replays the log into a finished SARIF file.
This staged design lets you build a run incrementally: hold one finding in working memory at a time, write it, move on. The final file is produced atomically by emit-finalize.
Step 1 — Initialize the run
Construct a SARIF Run JSON object — the same partial-Run shape consumed by SarifEventReplayer — and pipe it to emit-run. The verb accepts the run header via --input <path> or stdin, exactly like emit-results / emit-invocations. There is no flag-based form; if a field belongs on run.* in the final SARIF, place it on the JSON you supply here.
$runHeader = [ordered]@{tool = @{driver = [ordered]@{name = "{{SCANNER_NAME}}"semanticVersion = "{{SCANNER_SEMVER}}"informationUri = "{{SCANNER_INFO_URI}}"organization = "{{ORGANIZATION}}"}}versionControlProvenance = @([ordered]@{repositoryUri = "{{REPO_URI}}"revisionId = "{{COMMIT_SHA}}"branch = "{{BRANCH}}"mappedTo = @{ uriBaseId = "SRCROOT" }}# Add more entries as needed — submodules, additional checkouts,# cross-repo references. Attach a `properties` bag to any entry# (e.g., `properties.skills = @("xss-detector", "sql-tainter")`) to# document scanner/skill provenance for that source.)originalUriBaseIds = @{SRCROOT = @{ uri = "file:///{{LOCAL_SOURCE_ROOT}}" }}automationDetails = [ordered]@{guid = "{{NEW_GUID}}"}properties = @{"ai/origin" = "{{AI_ORIGIN}}"}} | ConvertTo-Json -Depth 32# Option A: pipe via stdin (matches emit-results / emit-invocations).$runHeader | dotnet dnx Sarif.Multitool --yes -- emit-run "{{OUTPUT_PATH}}"# Option B: write to a file and reference it.$runHeader | Set-Content run-header.jsondotnet dnx Sarif.Multitool --yes -- emit-run "{{OUTPUT_PATH}}" --input run-header.json
Inputs:
| Placeholder | Required | Notes | |
|---|---|---|---|
{{OUTPUT_PATH}} | yes | Final SARIF path, e.g. out/myscanner-<sha-short>.sarif. Staged event log is written alongside as <output>.wip.jsonl. | |
{{SCANNER_NAME}} | yes | run.tool.driver.name. Keep stable across model upgrades — it is the producer identity. | |
{{SCANNER_SEMVER}} | yes | SemVer 2.0 string for run.tool.driver.semanticVersion. | |
{{AI_ORIGIN}} | yes | One of generated, annotated, synthesized. See generating-sarif.md § AI Origin Declaration. | |
{{REPO_URI}} / {{COMMIT_SHA}} / {{BRANCH}} | yes | Populates the first run.versionControlProvenance entry. Required by rule AI1004. Add additional entries — each with its own properties bag if useful — to document submodules, additional checkouts, or per-source scanner/skill provenance. | |
{{LOCAL_SOURCE_ROOT}} | yes for snippet/hash enrichment | A file:// URI that the SDK can read to compute snippets and artifact hashes during emit-finalize. Rewritten to a portable URI in the finalize step. | |
{{NEW_GUID}} | yes | A fresh RFC 4122 GUID for run.automationDetails.guid. Required by rule AI2005. |
The verb validates a small set of profile-essential fields at receipt: tool.driver.name is required and must be a non-empty string; tool.driver.informationUri and versionControlProvenance[].repositoryUri must be https; originalUriBaseIds["SRCROOT"].uri must be https or file, and a file: source root must resolve to a directory that exists on disk when the run header is received, so emit-finalize can enrich result locations against an observable checkout; GUIDs must be canonical 8-4-4-4-12 strings; ai/origin must be one of generated, annotated, synthesized. Anything else the SARIF schema accepts on a partial Run is appended to the .wip.jsonl run-header event unchanged; note that emit-finalize materializes a typed SarifLog from that event log, so fields outside the SDK's typed Run model are dropped at finalize. Durable custom data should live in SARIF properties bags, which the typed model preserves.
When the TF_BUILD=True environment indicates an Azure DevOps pipeline, emit-run stamps automationDetails.id plus the four azuredevops/pipeline/build/* properties required by GHAzDO ingestion. If your JSON supplies any of those fields, the values must match what the env detects, otherwise the verb fails with a conflict diagnostic — pick one source of truth.
Outside a pipeline (TF_BUILD unset — a local or non-Azure-DevOps run), emit-run stamps neither automationDetails.id nor the azuredevops/pipeline/build/* properties, so the finalized log cannot be published to GHAZDO: it fails the GHAzDO ingestion contract (GHAzDO1014/GHAzDO1019/GHAzDO1020) and `publish-to-ghazdo` refuses it up front. A clean Sarif;AI validate does not imply publishability — see `publish-to-ghazdo` Step 3.
Step 2 — Append each result
For each finding, construct a complete SARIF result JSON object that conforms to docs/ai/generating-sarif.md § Result Structure, then append it:
# Option A: write the result to a JSON file, then point at it'@{ ... your result JSON ... }' | Set-Content result-001.jsondotnet dnx Sarif.Multitool --yes -- emit-results "{{OUTPUT_PATH}}" --input result-001.json# Option B: pipe the result JSON via stdinGet-Content result-001.json | dotnet dnx Sarif.Multitool --yes -- emit-results "{{OUTPUT_PATH}}"
The result JSON must include at minimum: ruleId (with sub-ID per AI1012, e.g. CWE-78/api-handler), level, message.text, message.markdown (AI1005), and at least one locations[].physicalLocation with a region.startLine. For security findings, also populate the ai/* keys the profile recommends — ai/exploitability and ai/attackerPosition (AI2014, and ai/evidence per AI2015 when present). These ai/* keys are SHOULD, not MUST, and AI2014's set is all-or-nothing: emit the whole group or none.
Batching. emit-results is polymorphic: pass a single result object or a JSON array of results. A batch is validated atomically — if any element is rejected, nothing is appended — and the verb reports { "appended": N, "rejected": [ { "index", "errorCode", "message" } ] } on stdout, so you can correct the offending elements by index and retry idempotently.
Stream vs batch — the trade-off. Both modes are first-class; choose by how you hold findings, not by volume (results are not necessarily high-cardinality):
- Stream one result per call as each finding is produced. Simplest to author, and each finding stands alone — a malformed finding fails only its own call and never blocks the others — but you pay a process spawn per result.
- Batch an array when you already hold several findings. One boundary crossing amortizes the spawn cost across the whole set. The cost you take on is atomicity: the batch is all-or-none, so a single invalid element — even the Nth — fails the entire submission and appends nothing. You don't get a partial write; you get per-index diagnostics (
{ "appended": 0, "rejected": [ { "index", "errorCode", "message" } ] }), fix the flagged elements, and resubmit. Because nothing was appended, the resubmit cannot double-append the elements that were already valid.
So the decision is spawn-cost-per-finding vs. blast-radius-of-one-bad-finding. Stream when findings are produced sparsely or their validity is uncertain (you'd rather a bad one not sink its neighbors); batch when you hold a vetted set and want to amortize the per-call spawn. Validation is identical per element either way — batching changes only the failure granularity, not the verdict.
Vocabulary discipline: only the eight ai/* keys defined in the profile are valid. Do not invent additional ai/* keys; place tool-specific data under a tool-named namespace instead (e.g. myscanner/confidence).
Step 3 — Append invocations (optional but recommended)
Use emit-invocations to record one or more Invocation objects (startTimeUtc, endTimeUtc, executionSuccessful, exitCode, commandLine, arguments, workingDirectory, environmentVariables, properties bag, …). The replayer appends invocations to run.invocations[] in event order. Like emit-results, the verb accepts a single object or a JSON array and validates the batch atomically.
Stream vs batch — the trade-off. Invocations differ from results in one consequential way: the endTimeUtc rule makes the two modes genuinely distinct, so the choice is not just spawn-cost bookkeeping.
- Stream one invocation per call, at the moment it concludes. A lone invocation object may omit
endTimeUtc— the verb stamps receipt time, which is ≈ coincident with the invocation's end when you write at conclusion. The producer carries no end-time bookkeeping, at the cost of one process spawn per invocation. - Batch an array of already-completed invocations in a single call. This amortizes the spawn cost, but you must supply
endTimeUtcon every element yourself: one write instant cannot stand in for N invocations that ended at different times, so the verb rejects (by index) any batched element missing it.
Rule of thumb: stream when invocations conclude sparsely and you'd rather we time-stamp them; batch when you've already accumulated several with their own end times recorded and want one boundary crossing.
Notifications travel inline on the invocation payload. Place each in the invocation's toolExecutionNotifications (execution narrative) or toolConfigurationNotifications (configuration feedback) array — the array selects placement. Descriptor ids name the concern only (e.g. DECISION, DATA-ACCESS-DENIED) — no AI/, EXEC/, CFG/, or <toolName>/ prefix. Every inline notification requires a producer-supplied timeUtc. See docs/ai/generating-sarif.md § Execution Narrative & Configuration Feedback for descriptor inventory and required shape.
Get-Content invocation.json | dotnet dnx Sarif.Multitool --yes -- emit-invocations "{{OUTPUT_PATH}}"
Step 4 — Register reporting descriptors (optional)
Two verbs append reportingDescriptor objects to the run's tool-driver catalogs. Both are producer-authored and validated at receipt against the same overlay schemas served by get-schema.
emit-notification-descriptorsappends a descriptor torun.tool.driver.notifications[]— the catalog that gives stable metadata (id, name, message strings) for the inline notifications recorded in Step 3.emit-rule-descriptorsappends a descriptor with aNOVEL-<kebab-sub-id>id torun.tool.driver.rules[]— for novel rules the producer defines. Taxonomy/CWE rule descriptors are injected by the SDK at finalize and must not be supplied here.
Both verbs are polymorphic (single object or JSON array) and validate the batch atomically; a duplicate id — whether already in the event log or repeated within the same batch — rejects the whole submission and appends nothing.
Get-Content notification-descriptor.json | dotnet dnx Sarif.Multitool --yes -- emit-notification-descriptors "{{OUTPUT_PATH}}"Get-Content rule-descriptor.json | dotnet dnx Sarif.Multitool --yes -- emit-rule-descriptors "{{OUTPUT_PATH}}"
Step 5 — Finalize and validate
dotnet dnx Sarif.Multitool --yes -- emit-finalize "{{OUTPUT_PATH}}" `--embed-text-files `--validate
What this does:
- Replays the
.wip.jsonlevent log into a final SARIF file. - Runs
InsertOptionalDataVisitoragainst the local source root to populate snippets, context regions, and artifact hashes. - Enriches CWE-as-rule-id descriptors from the embedded MITRE CWE taxonomy —
shortDescription,fullDescription,help, andhelpUri, in addition toname— from the embedded MITRE catalog (omit with--no-cwe-enrichmentif you've already populated descriptors, or want to avoid re-shipping public MITRE prose that AdvSec can look up from the id alone). Even with--no-cwe-enrichment, a CWE Weakness descriptor'snameis still resolved from the taxonomy unconditionally, so the descriptor stays spec-valid (SARIF1001/SARIF2012) and GHAzDO-publishable (GHAzDO2012) either way. - Rewrites
originalUriBaseIds["SRCROOT"]to a portable, commit-pinned root derived fromversionControlProvenance(a GitHub blob permalink such ashttps://github.com/<org>/<repo>/blob/<sha>/, or an Azure DevOps repository root) so the published log anchors at a host-independent location. - For GitHub-hosted runs only (host detected from
versionControlProvenance), collapses each result'sruleIdsub-id to its base CWE descriptor id (CWE-79/dom-xss-via-sanitizer-bypass→CWE-79), keepingresult.rule.idequal in step, so GitHub code scanning — which binds a result to its rule byruleId-string equality and does not follow hierarchicalruleIndexresolution — finds the descriptor and itssecurity-severity/tags. Azure DevOps / GHAZDO-hosted runs keep the authored sub-id intact. The sub-id carries no descriptor metadata, so nothing classifiable is lost; but it is not relocated either, so if you need the authored sub-classifier to survive finalize on a GitHub-hosted run, park it in a tool-namespacedresult.propertieskey (e.g.myscanner/subId) —result.ruleIdis not a durable carrier for it. See `generating-sarif.md § GitHub-hosted collapse`. - Embeds text-file artifact contents (
--embed-text-files). Useful for self-contained AI fixtures and to clearSARIF2013. - Runs the validator against the output with
--rule-kind Sarif;AI(--validate). Every run writes a structured JSON receipt —{ conforms, profile, errorCount, warningCount, noteCount, reportPath, errors }carrying the full error set — to stdout (the machine-readable twin of the emit batch verbs'{ appended, rejected }). On conformance the receipt carriesreportPath: nulland any prior<output>.validate-report.sarifis deleted — the report file is kept only on failure. If any Error-level finding is reported it additionally writes a concise per-error summary (rule id, location, message; capped at 20) to stderr — the channel a CI log reliably captures — persists the complete findings to<output>.validate-report.sarif, and exits non-zero. This covers theSarif;AIprofile only — not the GHAzDO ingestion contract. When your destination is GHAZDO, also validate with--rule-kind "Sarif;AI;GHAzDO"to exerciseGHAzDO1014/GHAzDO1019/GHAzDO1020offline; a run that is clean underSarif;AIcan still be rejected at ingestion (HTTP 400). See `publish-to-ghazdo` Step 3.
If --validate reports errors, the produced file is on disk but did not meet the profile. Read the verdict from the stdout receipt (parse it, or read the stderr summary in a CI log) and the full detail from <output>.validate-report.sarif. Treat this as a generation defect: fix the offending result or notification, regenerate, and re-finalize.
Repo-less scans (no version control). A scan of content that is not under version control — a local working copy with no remote, an unpacked container image, a downloaded package or tarball — has no repositoryUri to anchor to, so the run carries no versionControlProvenance and step 4 has no portable root to rewrite to. Pass --no-repo to emit-finalize in that case: it enriches descriptors and reads snippets exactly as above, then elides the transient local originalUriBaseIds root (dropping its uri rather than rewriting it) so no machine-specific path ships, and marks every run properties.unpublishable = true. That marker states the findings are outside version control; because every current code-scanning alert store anchors alerts to a repository and commit, an unpublishable run cannot be published (publish-to-ghazdo refuses it up front). Without --no-repo, a run lacking versionControlProvenance fails finalize by design.
Grouped findings (multi-run logs). To ship raw findings and higher-level grouped findings in one log — the ai/origin: generated / synthesized two-tier model — stage each tier as its own event log, then assemble them in order with --inputs:
dotnet dnx Sarif.Multitool --yes -- emit-finalize "{{OUTPUT_PATH}}" `--inputs generated.wip.jsonl synthesized.wip.jsonl `--validate
runs[i] corresponds to the i-th input deterministically (unlike merge, which reorders runs and does not rewrite pointers). That order-preservation is what cross-run sarif: result pointers — sarif:/runs/0/results/7 carried in a synthesized result's relatedLocations, linked with locationRelationship kinds includes / isIncludedBy — depend on. Each input is replayed, CWE-enriched, and VCP-rebased independently; --validate runs once over the assembled multi-run log. See `docs/ai/grouping-findings.md` for the full convention.
Validation
This skill's contract is satisfied when:
emit-finalize --validateexits with code 0 (no Error-level rule findings under--rule-kind Sarif;AI). This is theSarif;AIprofile only; if the destination is GHAZDO, a clean result here is necessary but not sufficient — also run--rule-kind "Sarif;AI;GHAzDO"(see Step 5 and `publish-to-ghazdo` Step 3).- The file passes the validate-sarif skill at full profile depth.
- The file is consumable by the SDK object model (
SarifLog.Load) without exceptions.
Any of these failing means the producer drifted from the profile. The validation skill's "Known Drift Patterns" catalog enumerates the most common drift modes — consult it when finalize fails.
Reference example
A complete reference SARIF file conforming to the AI profile is at `docs/ai/example.sarif`. The CWE-driven taxonomy sample at `src/Sarif/Taxonomies/CweGhasSample.sarif`, generated by `CweGenerateSample.ps1`, is the canonical SDK-generated sample and demonstrates the same emit-verb sequence in PowerShell.
Escalation
- Multitool unavailable — Install .NET 10+ for
dotnet dnx, ordotnet tool install --global Sarif.Multitool. Do not attempt to hand-author SARIF JSON: the SDK's emit verbs handle enrichment, validation, and consistency in ways that are difficult to replicate by hand. If you genuinely have no .NET environment, the profile doc is the source of truth — read it carefully — but expect to invest significant effort to match SDK output. - `emit-finalize --validate` reports persistent errors — Read the verdict from the stdout JSON receipt (or the stderr summary) and the full per-error detail (rule ID, location, message) from
<output>.validate-report.sarif. Cross-reference the rule ID withdocs/ValidationRules.mdand the AI rule list in the profile doc. If a rule appears wrong (false positive against a correct construct), file an issue against the SDK — do not silence the rule. - Source root not available locally — Omit
originalUriBaseIds["SRCROOT"]from the run header JSON. Snippets and artifact hashes will be empty;--embed-text-fileswill have no effect. The resulting log is still profile-conformant but less rich for consumers.