Code Scanning

Output Formats

xgrep emits human-readable text, Semgrep-compatible JSON, SARIF 2.1.0, a GitLab SAST report, or a CycloneDX 1.6 CBOM.

Output Formats

Text (default)

src/app.py:10:my-rule: Avoid using eval()

JSON

xgrep -f rules.yaml --json src/

JSON output is a Semgrep-compatible ScanReport with a results array. Each result carries check_id, path, start/end positions (each with line, col, and byte offset), and an extra object with a short human-readable title (schema_version 1.7), the full message, severity (INFO, WARNING, ERROR — the Semgrep-compatible names, kept stable for machine consumers), a promoted confidence (HIGH/MEDIUM/LOW, schema_version 1.7), captured metavars, rule metadata (CWE, OWASP, …), and the matched lines. xgrep additionally includes always-on context and remediation guidance per finding, plus a structured fix_info (concrete replacement edits and/or remediation hint) whenever the rule can offer one. Add --with-overview to prepend a code-graph summary of the scanned tree to the report (schema_version 1.1).

Human output (the terminal summary and the plain-text format) shows severity on the security-standard ladder — CRITICAL / HIGH / MEDIUM / LOW — while the JSON results[].extra.severity, SARIF level, and GitLab/LSP mappings keep their respective machine vocabularies. The two rank 1:1 (ERROR≡HIGH, WARNING≡MEDIUM, INFO≡LOW), and --severity/--max-severity accept either.

Run summary (run)

A --json report also carries a top-level run object (schema_version 1.6) — a machine-readable statement of what the scan did, distinct from the per-finding results. It lets an automated caller tell "scanned and uploaded N findings to <scope>" apart from "scanned but uploaded nothing" without inferring from the exit code:

"run": {
  "target": "https://github.com/acme/app",
  "scan": {
    "categories": ["secrets", "security"], "code": true, "dependencies": true,
    "files_skipped_out_of_scope": 12
  },
  "repository": { "url": "…", "branch": "main", "revision": "9f2c…", "on_default_branch": true },
  "findings": {
    "total": 42,
    "by_category": { "security": 30, "secrets": 12 },
    "by_severity": { "CRITICAL": 2, "HIGH": 18, "MEDIUM": 15, "LOW": 7 }
  },
  "upload": {
    "requested": true, "performed": true, "skip_reason": "",
    "scope_mrn": "//…/spaces/…", "asset": "acme/app",
    "findings_uploaded": 42, "findings_total": 42, "truncated": false,
    "dep_vex_uploaded": 3
  },
  "dependencies": {
    "requested": true, "performed": true, "skip_reason": "",
    "packages_submitted": 20, "packages_correlated": 6,
    "packages_uncorrelatable": 0, "advisories": 9,
    "ecosystems": [
      { "name": "maven", "submitted": 9, "correlated": 2 },
      { "name": "npm", "submitted": 11, "correlated": 4 }
    ]
  }
}

scan.files_skipped_out_of_scope (schema_version 1.10) is how many files production scope kept the security and correctness rules from running on — test, spec, fixture, example, and docs paths. It is a file count, not a finding count: those rules are skipped before they are evaluated, so the findings they would have produced were never computed. Those files were still read and secrets-scanned. The field is omitted when nothing was skipped (and under --include-tests, which turns production scope off), so its presence always means the scan covered less than the path you pointed at.

upload.performed reports whether findings actually reached Mondoo Platform, and upload.skip_reason explains a non-upload — one of incognito, no-credentials, not-default-branch, or error (empty when performed). truncated is true when the upload cap kept only the highest-severity findings (findings_uploaded < findings_total). The run object is emitted for the batch --json report only (not --stream); the human formats omit it.

Dependency coverage (run.dependencies)

dependencies (schema_version 1.11) reports what the dependency pass did, so an empty dependency result can be read correctly. Without it, "no dependency findings" has three indistinguishable meanings: nothing vulnerable, the pass never ran, or the advisory service returned nothing for the packages asked about.

  • performed is whether the pass actually ran — packages submitted and a response received. scan.dependencies carries the same value.
  • skip_reason explains a performed: false: disabled (--no-dep-scan), no-manifests, sbom-error, no-credentials, or error.
  • packages_submitted and packages_correlated are how many packages were sent for matching and how many of them at least one returned advisory landed on.
  • packages_uncorrelatable counts packages carrying no package URL. Matching is package-URL-based, so these can never match, whatever the advisory data holds — a different fact from "no advisory matched".
  • ecosystems repeats the split per package-URL type. This is the row to read when a result looks short: {"name": "maven", "submitted": 9, "correlated": 0} says nine maven packages were asked about and nothing came back for any of them, which is not the same statement as "these nine packages are fine".

The counts are descriptive. A clean project correlates zero packages, and xgrep does not editorialise: it reports the question it asked and the answer it got.

Incomplete file coverage (paths.partial, paths.timed_out)

paths.scanned lists every file the scan read. Two of them can be read without being fully analysed, because the per-file --timeout bounds how long any one file may take, and paths names both cases (schema_version 1.13):

{
  "paths": {
    "scanned": ["src/app.js", "vendor/bundle.js", "src/big.java"],
    "partial": [
      { "path": "src/big.java", "stopped_at_rule": "java-sql-injection", "rules_skipped": 326 }
    ],
    "timed_out": ["vendor/bundle.js"]
  }
}
  • partial — the file's deadline expired part-way through the rule list, so the rules after that point never ran on it. Its findings are real; there are just fewer rules behind them. stopped_at_rule is the rule being evaluated when the deadline hit and rules_skipped is how many never ran, which is the difference between a file that lost one rule and one that lost three hundred.
  • timed_out — the file's findings were discarded. Evaluation did not stop when asked, so the whole file's results were given up and nothing was reported for it.

Both are omitted when a scan loses no coverage, so their presence always means some of the report is incomplete. A file in either list stays in scanned — it was read, and scanned keeps meaning "what xgrep read" — so read these two as the caveats on it. Each affected file also gets a warning in errors naming what was lost.

Read them whenever an empty or shrunken result matters. Without them, a file whose results were thrown away is indistinguishable from a clean one, and a before/after comparison of two scans can differ purely because a large file truncated in one run and not the other. Comparing scans is the case that needs this most: exclude the files these two lists name, and what remains is comparable. Raise --timeout (or --timeout 0 to disable it) to shrink both lists.

Per-rule finding volume (rule_findings)

A --json report also carries a top-level rule_findings array (schema_version 1.8): for every rule that produced at least one finding, how many findings it made (count) and across how many distinct files (files_hit), sorted highest-count first. It is a compact per-rule volume rollup for spotting a single rule that dominates a scan's output, without walking the full results array:

"rule_findings": [
  { "id": "java-path-traversal-http-request", "count": 27, "files_hit": 9 },
  { "id": "generic-hardcoded-secret", "count": 3, "files_hit": 2 }
]

Every code-scan finding is counted once, including any later downgraded by severity or confidence. Suppressed (ignored) findings and dependency-vulnerability (SCA) findings are excluded — the rollup ranks code-scan rules, not advisories. The array is emitted for the batch --json report only (not --stream) and is omitted when no code-scan rule fired.

Original source positions for bundled code (original_location)

A finding in a bundled or minified JavaScript file is reported at a position nobody can act on — main.a1b2.js:1:48022 names a column in a file that is never edited. When the scanned file carries a source map, xgrep adds the position in the code the bundle was built from:

dist/main.js:1:489: AWS access key ID
  [aws-access-key-id] (MEDIUM) confidence: HIGH
  original: src/routes/user.ts:8:23 (via dist/main.js.map)
  | const accessKeyId = "AKIAQYLPMN5HXTB3CDEF";
  > AKIAQYLPMN5HXTB3CDEF

and, in --json, an original_location object on the finding (schema_version 1.12):

"original_location": {
  "path": "src/routes/user.ts",         // resolved against the map's directory
  "source": "../src/routes/user.ts",    // exactly what the map declares
  "line": 8,
  "col": 23,
  "name": "accessKeyId",                // the identifier before minification
  "snippet": "const accessKeyId = \"AKIA…\";",
  "source_map": "dist/main.js.map"      // or "inline" for a data: URL
}

This is an addition, never a replacement. path, start and end keep naming the generated file: they are what the byte offsets, the matched lines excerpt and any fix contract refer to, and a source map maps single positions rather than ranges, so it cannot re-describe a span.

The map is found the way a browser finds it: a trailing //# sourceMappingURL= comment (the legacy //@ and the /*# … */ forms are read too) naming a path relative to the bundle, a data:application/json;base64,… URL carrying the map inline, or — when the file declares nothing — a <file>.map sitting beside it. snippet and name come from the map's own sourcesContent and names; the original file is never read from disk, so the excerpt is always the revision that was actually built.

Three things it deliberately does not do:

  • A remote sourceMappingURL is not fetched. A map named by an http(s) URL is a build artifact that belongs beside the bundle; a scanner does not make network requests on the say-so of the file it is scanning.
  • An absolute sourceMappingURL is not followed. It is a path from the machine that produced the build, so on yours it names a different file.
  • A section of an index map that names its map by URL is not read. The sections form itself IS composed — see below — but a section that points at a map to fetch rather than carrying one is refused, for the same reason a remote sourceMappingURL is.

Whenever a map is absent, unreadable, or simply does not cover the finding's position — maps are sparse, and generated runtime glue maps to nothing at all — the finding is reported exactly as before, with no original_location. Nothing is guessed. Run with --verbose to see, per file, how many findings were offered to the map, how many resolved, and why the rest did not.

Both map shapes are read: the ordinary one, and the index map (sections) that describes one output file as a series of offset sub-maps. Every mapping form a bundler writes in practice has been checked against real output from esbuild, rollup, webpack and a rollup→terser chain, and against the decoder the rest of the JavaScript ecosystem reads maps with, over several hundred published .map files.

The reported source is the map's own string. For a bundle it is usually relative to the map (../src/routes/user.ts) and sometimes a generator's URL (webpack://app/src/user.js); sourceRoot is applied and ./.. inside the result resolve, matching what a browser or a stack-trace service shows for the same position. path is the best-effort resolution of that against the directory the map was read from, and is empty when the source is a pseudo-URL or an absolute path from the build machine — cases where no honest local path exists.

A bundle that carries a source map is scanned by default, which is what makes this remap reachable on a deployed front end without any flag — a vendored library's bundle stays skipped even though it ships a map of its own. See File filtering for the full rule and the flags that override it.

Streaming JSON (NDJSON)

xgrep -f rules.yaml --json --stream src/

--stream turns --json into newline-delimited JSON (NDJSON), emitted as each file is scanned rather than as one document at the end — so editors and tools can render findings while the scan is still running. Each line is a typed envelope:

{"type":"finding","check_id":"…","path":"a.py","start":{},"extra":{}}
{"type":"finding","check_id":"…","path":"b.py","start":{},"extra":{}}
{"type":"summary","findings":2,"files":2,"errors":0}

A finding line carries the same fields as an element of the batch results array, plus a type discriminator. The single trailing summary line closes the stream. Findings are emitted in file-completion order (not path-sorted), already deduplicated and production-scope-filtered per file, so the streamed set matches the batch report. --stream requires --json and cannot be combined with --sarif/--gitlab, --validate, --baseline-commit, --history, or --with-overview.

Consuming findings (light-mode & agent integrations)

A finding already carries everything an integration needs to render actionable output with no LLM in the loop — a review comment, a ranked triage entry, or a GitHub ```suggestion block. The fields most consumers under-read:

You want…Read this fieldNotes
A short headerextra.titleCurated per rule; distinct from the machine check_id and the paragraph message.
The problem / "why"extra.messageFull explanation.
The fix guidance ("how")extra.fix_info.hintProse remediation; present whenever the rule offers remediation.
A ```suggestion-ready patchextra.fix_info.patchWhole-line unified diff, present for deterministic fixes (ADR-0435). Render a suggestion straight from it — no byte→line conversion. See "GitHub suggestions" below.
A concrete replacementextra.fix / extra.fix_info.edits[]edits[] carry start_byte/end_byte (+ line/col) and a replacement string (the replacement for that byte span — may be a partial line). Prefer fix_info.patch for suggestions; edits[] are for SARIF/LSP. Present only for rules with an authored fix:/fix-regex:.
The exact span to rewritestart/end .col (and .offset)Column-precise, so a suggestion can target the vulnerable expression, not the whole line.
Ranking / triage signalextra.confidence, extra.severityconfidence is HIGH/MEDIUM/LOW; severity ranks 1:1 with the CRITICAL/HIGH/MEDIUM/LOW ladder.
Classification tagsextra.metadata.cwe, extra.metadata.owaspAlso category, likelihood, impact (a level, not prose), technology.
Taint source → sinkdataflow_traceSource location, intermediate steps, and sink for taint-mode findings.

Example (trimmed):

{
  "check_id": "go-weak-hash",
  "path": "crypto.go",
  "start": { "line": 3, "col": 34, "offset": 71 },
  "end": { "line": 3, "col": 45, "offset": 82 },
  "extra": {
    "title": "Weak hash",
    "message": "Use of weak hash algorithm detected (MD5 or SHA-1) …",
    "confidence": "HIGH",
    "severity": "WARNING",
    "fix_info": { "kind": "assisted", "hint": "Use crypto/sha256 or crypto/sha512 …" },
    "metadata": { "cwe": ["CWE-328: Use of Weak Hash"], "owasp": ["A02:2021", "A04:2025"] },
  },
}

Not every finding carries a concrete extra.fix — only rules with an authored fix:/fix-regex: template do (coverage is being widened; see ADR-0431). When extra.fix is empty, fall back to title + message + fix_info.hint. Agents that need to confirm a match or validate an authored fix in-sandbox can drive the xgrep mcp server's read-only scan and fix_verify tools rather than re-parsing output.

GitHub suggestions from a fix patch (ADR-0435)

Deterministic findings carry extra.fix_info.patch — a whole-line unified diff already anchored to source lines, so a light-mode consumer renders a ```suggestion with no byte→line math of its own. The xgrep fix verify/apply path emits the same tight patch (plus a wider, human-readable diff) on each assisted (agent-authored) FixOutcome, with an explicit verified boolean and the tier in kind.

Parse-free line ranges. Both surfaces also carry hunks — an array of {start_line, end_line} (1-based, inclusive) giving the original lines each hunk replaces, so you can anchor the suggestion without parsing the patch at all. Use hunks[i] for the comment range and the patch's +/context lines for the replacement text.

To turn one diff hunk into a suggestion:

  • the range is hunks[i] (or, from the patch, the @@ -l,s +… @@ header: ll+s-1);
  • the replacement text is the hunk's (context) and + lines with the one-character prefix stripped, in order (drop - lines);
  • anchor the review comment on that line range and emit the text verbatim inside a ```suggestion block.

A patch with exactly one hunk on one file (len(hunks) == 1) maps to a single suggestion. More than one hunk, or a cross-file changeset (FixOutcome.paths has >1 entry, ADR-0258), cannot be one suggestion — render prose instead. A \ No newline at end of file marker means the replaced region ends the file without a trailing newline; preserve that when applying.

SARIF

xgrep -f rules.yaml --sarif src/

SARIF 2.1.0 output integrates with GitHub Code Scanning and other SARIF consumers. See CI integration.

Findings suppressed inline with a # nogrep / # nosemgrep directive are omitted from SARIF by default. GitHub code scanning ignores the SARIF result.suppressions array, so a suppressed finding would otherwise import as an open alert — and a suppressed line that later moves can fail a pull-request check as a "new" alert. When suppressed findings are present, xgrep prints a warning to stderr noting they were omitted. To restore the old behavior and emit them with a result.suppressions array (for consumers that honor it), pass --sarif-include-suppressed. Other output formats (--json, text, --gitlab) are unaffected: they always carry suppressed findings, marked is_ignored: true.

When a scan is narrowed by production scope, the run's invocations[] entry carries the same file count as the JSON report, as the property files-skipped-out-of-scope:

"invocations": [
  { "executionSuccessful": true, "properties": { "files-skipped-out-of-scope": 12 } }
]

Like the JSON field, it counts files whose security/correctness rules did not run, and it is absent when nothing was skipped. The same invocations[] entry carries a toolExecutionNotifications message per scan-level error (a parse failure, a rule that would not load), so a partial or narrowed scan is never indistinguishable from a clean full one.

GitLab SAST

xgrep -f rules.yaml --gitlab -o gl-sast-report.json src/

GitLab's Vulnerability Report consumes its own Secure SAST format rather than SARIF, so use --gitlab (not --sarif) for GitLab. The report maps each finding to a GitLab vulnerability with a stable id (for cross-scan tracking), a title-cased severity (Critical/High/Medium/Low), a line-based location, and identifiers for the rule plus any CWE/OWASP metadata. Publish it as a reports: sast: artifact in your GitLab CI job. See CI integration.

CBOM (Cryptography Bill of Materials)

A CBOM is a bill of materials, so it is produced by the sbom command rather than as a scan output format:

xgrep sbom --include cbom .

This emits a CycloneDX 1.6 Cryptography Bill of Materials — a machine-readable inventory of the cryptographic assets in your code (algorithms, key material, protocols, certificates), enriched with OID, primitive, key size, and classical/post-quantum security levels. It is detected by the scan engine but does not add noise to a normal xgrep scan. See CBOM for the full guide: language/library coverage, the asset and dependency-graph structure, and how to consume the document.

Reporting to Mondoo Platform

The formats above all write locally — to stdout or a file. xgrep additionally uploads findings to Mondoo Platform whenever a Mondoo service account is configured (via xgrep login or a config file), where they attach to the scanned repository as an asset. Reporting is automatic on the repository's default branch, additive (local output is unchanged), and best-effort (a failed upload never fails the scan):

xgrep scan .              # reports automatically when logged in (default branch only)
xgrep scan --incognito .  # local only; upload nothing

See Mondoo Platform for credentials, asset/severity mapping, and CI usage.

On this page