Feeding findings into cxg
An @exposes annotation is a hypothesis: this asset, this threat, this code
location, nobody has shown it holds. cxg is what turns a hypothesis into a
confirmed or refuted finding by attacking a running instance.
The interface between them is one file. guardlink writes SARIF; cxg pentest
reads it. This page covers the guardlink half: what the export contains, what
makes a finding useful downstream, and what never crosses.
The handoff
Section titled “The handoff”mkdir -p whiteboxnpx guardlink sarif . -o whitebox/findings.sarif✓ Wrote SARIF to whitebox/findings.sarifSARIF: 1 result(s) — 1 error(s), 0 warning(s)cxg pentest run --codebase <dir> loads <dir>/whitebox/findings.sarif. The
path is fixed, so the filename and directory above are not a convention you can
change. See cxg pentest.
The -o flag does not create the directory. Without the mkdir -p, guardlink
exits 1 with a raw Node ENOENT stack trace rather than a message.
Without -o, SARIF goes to stdout, which is what you want in a pipeline. The
SARIF: 1 result(s) line goes to stderr either way.
What crosses
Section titled “What crosses”SARIF 2.1.0, one run, five rules:
| Rule | Level | From |
|---|---|---|
guardlink/unmitigated-exposure |
warning | @exposes with no matching @mitigates or @accepts |
guardlink/unmitigated-critical |
error | the same, at critical or high |
guardlink/confirmed-exploitable |
error | @confirmed |
guardlink/parse-error |
error | a malformed annotation |
guardlink/dangling-ref |
warning | a #id that resolves to nothing |
A single result:
{ "ruleId": "guardlink/unmitigated-critical", "level": "error", "message": { "text": "#users is exposed to sqli: email is interpolated into the SQL string" }, "locations": [ { "physicalLocation": { "artifactLocation": { "uri": "src/users.js" }, "region": { "startLine": 3 } } } ], "partialFingerprints": { "guardlink/threatId": "gl-5cc827267186" }, "properties": { "threatId": "gl-5cc827267186", "severity": "critical", "asset": "#users", "threat": "#sqli", "codegraph_reachability": { "http_method": "GET", "http_path": "/api/users" } }}Three fields carry the weight.
threatId, the identity that survives the round trip
Section titled “threatId, the identity that survives the round trip”gl- plus twelve hex characters, derived from the normalised asset, the
normalised threat, and the file. Not the line, and not the message text.
That means it is stable across re-runs and across code movement, and it is the
same id whether the annotation is an @exposes or the @confirmed that later
proves it. That is deliberate, because those are the same threat at the same place at
two points in its life. Downstream tools carry the string and compare it; they
never recompute it. guardlink is the sole authority for the algorithm.
It is emitted twice: in partialFingerprints, which is the SARIF-native
mechanism any SARIF consumer understands, and in properties, for consumers
without a SARIF library.
The accepted limitation: two genuinely distinct weaknesses at an identical (asset, threat, file) collapse to one id.
codegraph_reachability, where to point the traffic
Section titled “codegraph_reachability, where to point the traffic”A source-code location does not tell an HTTP scanner what to request. This does,
and it comes from your @flows annotations.
guardlink builds it by looking for a flow whose mechanism is a route, written
METHOD./path:
@flows #api -> #users via GET./api/usersThat produced the codegraph_reachability block above. The method must be one of
GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS; the path must start
with /. A query string is stripped, and so is anything in parentheses.
The route is matched to a finding by the flow’s own source file first, then by
the flow’s target asset. So a @flows written in the handler file wins over one
written elsewhere, which is what you want when an asset fronts several routes.
No route annotation, no reachability field. The finding still exports, and a downstream tool has nothing but a file path to work from. If you want your annotations tested against a running service, annotating the routes is the highest-value thing you can do, higher than adding more exposures.
The path is emitted verbatim, with no base path assumed. If your service is
mounted under /v2, write /v2/api/users.
severity, the rule and the level
Section titled “severity, the rule and the level”critical and high are what make guardlink emit unmitigated-critical at
error level rather than unmitigated-exposure at warning. cxg ranks the
hypotheses it loads against the operator’s stated goal, so severity is an input
to that ranking rather than the whole of it.
Severity on an @exposes is optional; when omitted it inherits from the
@threat declaration. Written as:
@exposes #users to #sqli -- "no severity written here"against @threat SQL_Injection (#sqli) [critical], the exported result is
guardlink/unmitigated-critical at error, with "severity": "critical".
Leaving it unset in both places is how a real finding ends up carrying nothing to
rank on.
Filter before exporting if you only want the top of the list:
npx guardlink sarif . --min-severity high -o whitebox/findings.sarifWhat does not cross
Section titled “What does not cross”@mitigates and @accepts remove the exposure entirely. They do not
annotate it, flag it, or downgrade it. A covered (asset, threat) pair is absent
from the export, so it never becomes a finding and never gets probed. That is the
intended behaviour, and it is also the failure mode to keep in mind: a mitigation
you wrote and have not verified silently removes the thing that would have
verified it.
guardlink sarif writes the same file whether or not the control it names still
exists. Deleting an @exposes and deleting a real risk look identical from
downstream.
@entitles crosses in neither direction, by design. An entitlement records
that an actor is legitimately allowed a capability. SARIF for a model with
entitlements is byte-identical to one without: the exposure is exported the same,
probed the same, and a reader of the file cannot tell an entitlement exists. Only
the downstream recommendation changes.
The reasoning is worth stating, because it is the opposite of how a suppression usually works: a suppression that also prevents verification is how a threat model becomes confidently wrong. An over-granted entitlement should be able to change how a finding is written up. It should never be able to stop the finding being tested.
The coverage block does not appear in SARIF at all. No field in the export carries it.
Before you export
Section titled “Before you export”Four things, in the order they cost you the most.
- Annotate the routes. Without
codegraph_reachability, a finding names a file and nothing an HTTP client can act on. - Set severity. It decides ordering and rule level.
- Make sure the exposures you care about are still exposures. Anything mitigated or accepted is gone from the file.
- Check the file count.
SARIF: N result(s)on stderr, against whatguardlink status .reports as unmitigated. A number lower than you expect usually means a@mitigatesyou forgot about, or--min-severity.
Related
Section titled “Related”- The threat model as an artifact is the model this export is a projection of.
guardlink sarifdocuments every flag on the exporter.cxg pentestis the consumer, with its flags and what it does with the file.- bugb is the wrapper that runs both ends of this.

