Skip to content

Lesson 11: Attack surface

The attack-surface command answers a question every reviewer eventually asks: what comes in, and what can it touch? It groups a report's entry points into exposure tiers and reports what each one reaches, in the shape of dosai's own AttackSurface[] view, for every engine.

The tier ladder

Entry points are grouped by how exposed they are, most exposed first:

    anonymous-http    an unauthenticated HTTP route
    anonymous-rpc     an unauthenticated RPC
    anonymous         unauthenticated, protocol not HTTP or RPC
    mcp               an MCP server tool
    queue             a queue consumer
    cli               a command line entry point (local trust)
    authenticated-http      an HTTP route with authentication
    authenticated-rpc       an RPC with authentication
    internal          declared unreachable for callers (deny rules, non-exported)
    unknown-auth      the engine could not say (rendered as a gap, never as safe)

Two engines can fill this ladder from evidence: dosai classifies authentication outright, and kosi carries declared requirements from route DSLs, servlet constraints and manifests. Every other engine's entries, and kosi's undeclared ones, sit in unknown-auth. That tier is printed with a standing reminder that it is a gap in the evidence, not a verdict of safety. The header of the output says which situation you are in:

tiers run most-exposed first; only dosai classifies authentication — every other
engine's entries sit in unknown-auth, which is a gap, not a finding.

(with kosi the first clause becomes "dosai and kosi classify authentication").

The dosai report in the cdxgen tree

You met this report in lesson 10. Its attack surface is small and makes a clean first example:

$ atom-tools attack-surface -i ~/work/cdxgen/cdxgen/data-flow.slices.json
Attack surface: 2 entry point(s) from 1 report(s) (dosai)
tiers run most-exposed first; only dosai classifies authentication — every other
engine's entries sit in unknown-auth, which is a gap, not a finding.

  * cli: 2 entry point(s), 46 weakness(es) (27 high severity)
   ├── ANY ci/images/nuget/nuget.exe  ci/images/nuget/nuget.exe:1
   │     reach: engine · sinks: command, file, network, xxe
   └── ANY ci/images/nuget/Lucene.Net.dll  ci/images/nuget/Lucene.Net.dll:1
         reach: engine · sinks: file, log, reflection

tiers with no entry points here: anonymous-http, anonymous-rpc, anonymous, mcp,
queue, authenticated-http, authenticated-rpc, internal, unknown-auth
headline suppressed: no input classifies authentication, so no entry point is
known to be anonymous — a percentage here would invent a denominator.

Read it line by line. Two CLI entry points, dosai's own reachability verdict (reach: engine) and the sink categories each reaches. The last line is the honesty habit in its purest form: the tool refuses to print a "percentage anonymous" headline because no input classifies authentication here, and a percentage over an invented denominator would be decoration pretending to be a measurement.

Reach lines and their four shapes

On the golem fixture (Go, ipsw), reach is computed by anchoring each endpoint's handler in the engine call graph and attaching the flows whose source function falls inside its transitive closure. The output shows every shape a reach line can take:

$ atom-tools attack-surface -i test/data/ecosystem/go-ipsw-golem.json
Attack surface: 57 entry point(s) from 1 report(s) (golem)
...
  ? unknown-auth [auth unknown — not an exposure verdict]: 57 entry point(s), 0 weakness(es)
   ├── /GET /  .../internal/commands/ent/ui.go:35  [auth unknown]
   │     reach: seed-not-found (handler not locatable in the call graph)
   ├── GET /_ping  .../api/server/routes/daemon/routes.go:40  [auth unknown]
   │     reach: computed — reaches no analysed flow
   ├── GET /dsc/imports  .../api/server/routes/dsc/routes.go:78  [auth unknown]
   │     reach: 6 flow(s) · sinks: filesystem, http-response, logging
   │     packages: pkg:golang/github.com/blacktop/ipsw, pkg:golang/github.com/gin-gonic/gin
   ├── GET /idev/amfi/dev  .../api/server/routes/idev/routes.go:21  [auth unknown]
   │     reach: 68 flow(s) · sinks: crypto, http-response, panic
   ...

The four shapes, and what each one claims:

reach: N flow(s) · sinks: ... is a computed closure with flows attached. The endpoint's handler was found in the call graph, and the listed sink categories are reachable through it.

reach: computed — reaches no analysed flow says the closure was computed and found nothing. The handler exists, the graph was walked, nothing analysed flows from it. Note the qualifier "analysed": the claim is about this report, not about the whole program.

reach: seed-not-found says the handler could not be anchored in the call graph at all. The endpoint declaration exists but the join failed. Treat it as a review flag on the report, not on the code.

reach: not computed (not shown by this fixture) appears when the input has no call graph to walk. "Reaches nothing" and "not computed" are opposite findings, and the renderings keep them distinct.

kosi: declared requirements, quoted not derived

The kosi fixture for a Kotlin routes DSL fills the authenticated and internal tiers from declarations:

$ atom-tools attack-surface -i test/data/ecosystem/kotlin-dsl-media-auth-kosi.json
Attack surface: 17 entry point(s) from 1 report(s) (kosi)
tiers run most-exposed first; dosai and kosi classify authentication — ...

  * authenticated-http: 9 entry point(s), 0 weakness(es) (0 high severity)
   ├── /secure  src/main/kotlin/fixtures/dslmedia/DslRoutes.kt:155  [kosi declares auth-handler(BasicAuthHandler)]
   │     reach: computed — reaches no analysed flow
   ├── GET /admin  src/main/kotlin/fixtures/dslmedia/DslRoutes.kt:178  [kosi declares role(ADMIN)]
   │     reach: computed — reaches no analysed flow
   └── GET /token  src/main/kotlin/fixtures/dslmedia/DslRoutes.kt:155  [kosi declares auth-handler(JWTAuthHandler)]
         reach: computed — reaches no analysed flow

  * internal: 1 entry point(s), 0 weakness(es) (0 high severity)
   └── GET /denied  src/main/kotlin/fixtures/dslmedia/Servlets.kt:19  [kosi declares a deny rule (security-constraint(denied)): no caller may reach this endpoint]
         reach: computed — reaches no analysed flow

  ? unknown-auth [...]: 7 entry point(s), 0 weakness(es) (0 high severity)
   ...

Each bracket prints what kosi actually read: auth-handler(BasicAuthHandler), role(ADMIN), security-constraint(denied). The mapping is conservative. A non-empty authentication list lifts the endpoint to authenticated-http; a deny rule plus exported: false maps to internal; an empty declaration is not a denial, so endpoints without a declared requirement stay in unknown-auth, and "anonymous" is never asserted for anything that did not say so.

One more kosi behaviour deserves its own paragraph. A declared route whose handler code was never read is flagged by kosi as substantiated: false (think of an Android manifest naming a class that is not in the tree). atom-tools keeps it in the surface, because it is a real declared entry point, and marks it declared only, handler code not read. Dropping it would hide an entry point; publishing it unmarked would present an unexamined route as a clean one. The Android manifest fixture shows every one of these mechanics at once:

$ atom-tools attack-surface -i test/data/ecosystem/kotlin-android-manifest-app-kosi.json
Attack surface: 5 entry point(s) from 1 report(s) (kosi)

  * internal: 2 entry point(s), 0 weakness(es) (0 high severity)
   ├── PackageReceiver  src/main/AndroidManifest.xml:1  [kosi records the component as not exported (not externally launchable)]
   │     reach: computed — reaches no analysed flow
   └── SyncService  src/main/AndroidManifest.xml:1  [kosi records the component as not exported (not externally launchable)]
         reach: computed — reaches no analysed flow

  ? unknown-auth [auth unknown — not an exposure verdict]: 3 entry point(s), 0 weakness(es) (0 high severity)
   ├── MetaProvider  src/main/AndroidManifest.xml:1  [auth unknown]
   │     reach: computed — reaches no analysed flow
   ├── android.intent.action.MAIN  src/main/AndroidManifest.xml:1  [auth unknown]
   │     reach: computed — reaches no analysed flow
   └── android.intent.action.VIEW  src/main/AndroidManifest.xml:1  [auth unknown]  [declared only — handler code not read]
         reach: not analysable — the engine read none of this endpoint's code, so
         zero flows here means unexamined, not clean

Three entry point kinds, three renderings. A component the manifest says is not exported lands in internal with the reason quoted. A provider with no declaration lands in unknown-auth like any other undeclared entry. And the VIEW intent whose handler was never read stays in the surface, marked declared only, with its reach line saying outright that zero flows there mean unexamined, not clean.

Formats and filters

The console rendering is bounded by --max-entries (default 200). The JSON output always carries every entry point, which is the one to consume from scripts:

$ atom-tools attack-surface -i test/data/ecosystem/kotlin-dsl-media-auth-kosi.json -f json -o kosi-as.json
Attack surface with 17 entry point(s) written to kosi-as.json.

-f html writes a mermaid diagram plus a single-file page. --min-exposure keeps only tiers at least as exposed as the named one (--min-exposure cli keeps cli and everything above it); tiers whose authentication is unknown never match the filter, and the note counts them, so filtering can never silently delete the unknown tier.

Exercises

Point attack-surface at the unified document from lesson 10 and watch the tiers of three engines render in one table. Then run it on the atom reachables you generated for cdxgen in lesson 2 and check which tier a CLI tool's entry points land in.

$ atom-tools attack-surface -i unified-mixed.json
$ atom-tools attack-surface -i cdxgen-reports/cdxgen.reachables.json