Boundaries

Attack surface

cg surface reads facts the index already holds and answers three questions: what does the code expose, what does it connect to, and where are credentials and transport protection weak. Nothing is rescanned: the checks that need source text (disabled verification, insecure gRPC channels, risky IPC settings) run once at index time and leave facts on the graph. A secret value is never stored or printed: a finding names the kind and the location, and the index keeps only a redacted marker for a credential literal (No secret values in the index).

cg surface --db out/graph.db                          # findings, grouped by type then protocol
cg surface --db out/graph.db --inbound --outbound     # plus both surface listings
cg surface --db out/graph.db --format sarif > cg.sarif
cg surface --db out/graph.db --fail-on hardcoded,unverified   # exit 1 in CI

What it lists

surfaceitemsper item
--inboundHTTP routes, webhooks, WebSocket / Socket.IO / SSE, gRPC / GraphQL / tRPC / JSON-RPC handlers, message consumers, TCP / UDP listeners, Unix sockets, modelled IPC handlers (Electron, Tauri, Android)guard state, whether it reaches a write (as cg routes --writes), protocol, transport, bind exposure for listeners
--outboundevery external: system (External systems)address source, credential source, TLS, entry points that reach it

Guard state is guarded, secret-checked (a signature or shared-secret check), inline-guarded (an unconditional check inside the action), public (a route in the public-by-design table), unguarded, or unchecked when the protocol records no guards. Socket.IO handlers in a client bundle are not an inbound surface and are left out.

Findings

findingtriggersseverity
hardcodedan external system or SDK call with a literal credential (credential_source=literal, credential_literal, an edge with literal_credential); a DSN with a password in it; a config key named like a secret (password, token, secret, *_key) with a literal value, or an env('X', 'literal') default for a secret-looking variablehigh
plaintextan external system with tls=false and a non-loopback host (http://, ws://, redis://, amqp://, ftp://, ldap://, plain SMTP, a Docker TCP host); also Telnet / TFTP, MQTT on 1883 and SMTP on 25 at heuristic; and a gRPC channel or port created without TLS (protocol grpc, see below)medium
unverifieda webhook route that reads a provider header and never verifies it (Protocol links)high
unguardeda route or handler with no guard after the guard rules: the cg routes --unguarded rule plus framework defaults, checks at the top of the handler and the public-route table; --strict keeps route guards onlymedium when nothing looks like a check and a write is reached; low otherwise; heuristic confidence when unclear
exposed-listenera TCP / UDP listener bound to all interfaces (0.0.0.0, ::, no host)medium
tls-offcertificate verification or SSH host key checking switched off in code or configuration (table below)high
ipc-exposedlocal IPC open to callers it should not trust (table below), for what unguarded does not already reportmedium
ssrfthe host or the whole URL of an outbound HTTP call is built from request input and no allow-list check precedes it (see "Outbound URLs from request input"); a DNS lookup of request input is reported at the lower severityhigh (DNS lookup of input: medium)

How unguarded decides

cg surface starts from the cg routes guard state and then, unless --strict is given, applies these rules in order. A route that gets a guard from a rule is not reported; a rule that cannot decide leaves it reported at heuristic confidence and low severity, with the reason in the detail (unclear: ...).

stepruleby framework
1. project defaultsa guard that applies to every route of the project is added to the routeDjango REST framework: REST_FRAMEWORK DEFAULT_PERMISSION_CLASSES of the settings file (an AllowAny default is not a guard; a view with permission_classes = [] is explicitly public); Laravel: global $middleware and $middlewareGroups of app/Http/Kernel.php or withMiddleware() of bootstrap/app.php, by the group of the route file; Nest: APP_GUARD providers (already recorded), removed from a route that has @Public() or a similar opt-out decorator (SkipAuth, NoAuth, AllowAnonymous, ...); Express: app.use(auth) that comes before the route in the same router and whose mount path covers it (app.use('/admin', auth) guards /admin/* only); Express session(), cookie-session and express-session are never auth
2. public by designthe route matches the public tableall
3. check in the handlerwithin the first 14 code lines of the handler (not past its end): an if / unless whose condition mentions a session, user, token, permission, role, admin flag, Authorization or API key and that returns, throws, aborts, redirects or answers 401 / 403; or a leading call such as requireAuth(), authorize(), verify...(), check...() or abort_unless() whose name or argument mentions the user, auth, token, role, permission, signature or key. A Socket.IO endpoint is checked in its connection handler first (io.use(...) or on('connection')), then in its own first statements. Laravel auth()->check(), Auth::user() and $request->user() are inline guardsall; PHP, Python and TypeScript
4. uncleara middleware or decorator whose name looks like a check but is not in the presets, a webhook receiver whose verification is not recognised, a call in the handler whose name looks like a check, a GraphQL endpoint (access is per field)heuristic, low

Severity: medium when no step applied, nothing in the handler looks like a check, and the handler reaches a database write (cg routes --writes); low for a read-only handler, a metrics endpoint and every heuristic case. --strict reports every route with no route-level guard at medium severity, as before part 2d.

Per-framework notes: Django resolves a DRF view routed by a plain path() as a DRF view, so the settings default applies; Laravel resolves the route file's group (web, api) and the global stack; Nest reads @Public(), @SkipAuth(), @AllowAnonymous(), @NoAuth() and similar decorators as opt-outs; Express guards depend on registration order, because app.use(auth) guards only routes registered after it.

Public-by-design routes

These patterns are matched on the route path (segments are case-insensitive, parameters are ignored). A match makes the route public and removes it from unguarded. metrics is the exception: it stays reported, at low severity.

classexamples of matching path segments
sign-in / sign-up / password resetlogin, logout, signin, signup, register, forgot-password, reset-password, verify-email, csrf
health checkhealth, healthz, ready, readyz, live, livez, ping, status, up, alive
robots / sitemap / favicon / manifestrobots.txt, sitemap*.xml, favicon.ico, manifest.json, security.txt, humans.txt, ads.txt
static assets, media, thumbnailsstatic, assets, public, media, _next, locales, fonts, images, thumbnails, avatars, emoji, logo
jwks / .well-known.well-known, jwks
OAuth / OIDC callback or token endpointoauth, oidc, openid, callback, authorize, sso, saml, /token
API schema / documentationswagger, redoc, openapi, api-docs, /api/schema
utility endpointversion, ip, geolocation
scheduled job endpointcron (the job checks its own key)
tRPC adapter, Auth.js / NextAuth handler/api/trpc/* (each procedure carries its own middleware), [...nextauth]
embed pages, shared link, site rootembeds, /s/, /share/, /public/, /
test / fixture codetest, tests, __tests__, e2e, fixtures, *.test, *.spec
metrics (low severity, still reported)metrics, prometheus, stats

The table is a heuristic about names. A route named /status that is not public is hidden by it; add nothing to the graph for that, but use --strict to see every route without a route-level guard. A public route that is not in the table needs a surface.ignore entry.

Disabled verification (tls-off)

The index records each hit on the function or method that contains it (the module node at file level, a file:config:<path> node for scripts and config files), as attrs.insecure_transport. Test files and test callers are skipped and counted (tests_skipped in the index stats). .env files are never read: only .env.example style files, compose files, Dockerfiles, package.json scripts, CI files, shell scripts, ssh_config and ansible.cfg.

languageTLS verification offSSH host key not checked
Pythonverify=False (requests, httpx, urllib3), aiohttp ssl=False, ssl._create_unverified_context, CERT_NONE, check_hostname = Falseparamiko AutoAddPolicy / WarningPolicy, asyncssh known_hosts=None
TypeScript / JavaScriptrejectUnauthorized: false (https, tls, undici, got, axios httpsAgent), strictSSL: false, process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'ssh2 hostVerifier: () => true
PHPCURLOPT_SSL_VERIFYPEER false, CURLOPT_SSL_VERIFYHOST 0, Guzzle 'verify' => false, Http::withoutVerifying(), stream context verify_peer(_name) => falseStrictHostKeyChecking=no in strings
Kotlin / Javatrust-all X509TrustManager (empty checkServerTrusted), a HostnameVerifier that returns true, OkHttp hostnameVerifier { _, _ -> true }JSch setConfig("StrictHostKeyChecking", "no")
Rustdanger_accept_invalid_certs(true) / danger_accept_invalid_hostnames(true)
GoInsecureSkipVerify: truessh.InsecureIgnoreHostKey()
DartbadCertificateCallback = (...) => true
scripts, configNODE_TLS_REJECT_UNAUTHORIZED=0 in .env.example, compose, Dockerfile, package.json scripts and CI files (process-wide; the detail says so)StrictHostKeyChecking=no / UserKnownHostsFile=/dev/null in shell scripts, CI files and in-repo ssh_config; host_key_checking = False in ansible.cfg and ANSIBLE_HOST_KEY_CHECKING=False (heuristic)

SSH host keys are reported under tls-off with protocol ssh and a detail that says "SSH host key"; TLS hits have the protocol of the system they resolve to (https, ...) or tls. A hit inside a call that is already a CONNECTS_TO edge (cg external) or an HTTP_CALLS edge (cg api-calls) also puts tls_verify: false on that edge and its external system, and both commands print tls verify off. Verification left on (verify=True, rejectUnauthorized: true, StrictHostKeyChecking yes, a pinned hostVerifier) is not a hit. A line inside a comment is not a hit.

Outbound URLs from request input (ssrf)

An index-time pass (cg_code_graph/ssrf_input.py) walks each non-test function: it collects request-input sources, follows local variables assigned from them (also template / f-string / concatenation / sprintf / .format / %, new URL(x) and urljoin), and looks at the URL argument of outbound calls and DNS lookups. cg surface only reads the result; nothing is rescanned.

languagerequest inputoutbound sinksDNS lookups
TypeScript / JavaScriptreq.query / body / params / headers / cookies, getQuery(event), readBody(event), getRouterParam(event, 'k'), c.req.query() / param() / json(), request.nextUrl.searchParams.get, await request.json(), Nest @Query() / @Body() / @Param() / @Headers() parameters, Elysia { query, body, params }fetch, $fetch, axios, got, ky, needle, superagent, undici, node http / httpsdns.lookup, dns.resolve*, dns.promises.*
PHP$request->input / query / get / post / json / all / route / header / cookie, request('k'), $_GET / $_POST / $_REQUEST / $_COOKIE, route parameters of controller actionsLaravel Http::, Guzzle clients, curl_init / CURLOPT_URL, file_get_contents, fopengethostbyname, dns_get_record
PythonDjango request.GET / POST / data / query_params / headers / META, Flask request.args / form / json / values / headers, FastAPI / Starlette handler parameters, Django URL kwargsrequests, httpx, aiohttp (module calls and sessions / clients), urllib urlopensocket.gethostbyname, socket.getaddrinfo

Each use is recorded as url_from_input = {source, key, part, via, checked}: source is query, body, param, header, path or cookie; key is the input name (* when the whole container is used); part is what the input controls; via is direct (same function) or helper (one level: a handler passes the value to a function whose URL uses that parameter, found through the CALLS edge); checked is true when a comparison with a constant allow-list (in ALLOWED_HOSTS, ALLOWED.has(u.host), in_array($host, self::ALLOWED)) or a startsWith / startswith / str_starts_with test against a literal https://... prefix precedes the call. Only names are stored, never a value from a request.

partmeaningreported
urlthe whole URL, a template that starts with the input, or new URL(x) / urljoin(base, x) (an absolute x replaces the host)yes
hostthe host in https://${x}/... or a DNS lookup nameyes
path, queryinput after a fixed host (https://api.example/${id}, ...?q= + x)no, recorded only
any, with checkedan allow-list check precedes the callno, recorded only

The fact sits on the HTTP_CALLS / CONNECTS_TO edge of the call when the call has one (cg api-calls and cg external print url from input (query.url, url); host checked and via helper are added when they apply). A call with no edge keeps the fact on the enclosing function, as an insecure_transport fact of kind ssrf or dns-input. The finding has confidence resolved for a direct flow and heuristic through a helper, and its entry points are the routes that reach the function. A DNS lookup of input is an ssrf finding with the detail "DNS lookup of request input" at medium severity (it resolves a chosen name but sends no request body). --finding ssrf, --fail-on ssrf, surface.ignore, SARIF and the MCP tool need no special handling. Test files and test callers are skipped and counted (tests_skipped).

$ cg surface --db out/graph.db --finding ssrf
== ssrf (3) - Outbound request to a host chosen by request input ==
  dns
    MEDIUM [resolved] function:shop.views.mirror
      DNS lookup of request input (query.host)
      at shop/views.py:36
      reached from http_route(1)
  http
    HIGH [heuristic] function:shop.feeds.pull_feed
      the whole outbound URL comes from request input (body.feed) via helper, passed by shop.views.import_feed
      at shop/feeds.py:5
      reached from http_route(1)
    HIGH [resolved] function:shop.views.cover
      the whole outbound URL comes from request input (query.url)
      at shop/views.py:14
      reached from http_route(1)

$ cg external --db out/graph.db
external:https:covers.bookstore.test:443  [exact]
    <- function:src/app.ts#coverByIsbn @ src/app.ts:15  (http)  [url from input (param.isbn, path)]

Insecure gRPC (plaintext, protocol grpc)

A second producer under the plaintext name: Python grpc.insecure_channel / add_insecure_port, TypeScript credentials.createInsecure() / ServerCredentials.createInsecure(), Java and Kotlin ManagedChannelBuilder...usePlaintext(), Go grpc.WithInsecure() / insecure.NewCredentials(), Rust tonic Channel::from_static("http://..."), Dart ChannelCredentials.insecure(). A unix: target (already a local socket) and a loopback target are not findings; the index counts them (grpc_unix, grpc_loopback). A target that is not a literal is reported at heuristic.

IPC exposure (ipc-exposed)

triggerprotocolsource
window.postMessage(msg, '*')postmessagethe SENDS_TO edge's target_origin: "*" (Protocol links)
a Unix socket whose recorded mode is world-writable (0o666, 0o777, o+w)unixattrs.mode on endpoint:unix:<path>
manifest.json externally_connectable with wildcard matches (*://*/*, <all_urls>) or ids: ["*"]extensionindex-time pass
runtime.onConnectExternal / onMessageExternal listener with no sender.id / origin / url checkextensionindex-time pass
Electron webPreferences with nodeIntegration: true, contextIsolation: false or webSecurity: falseelectronindex-time pass

A message listener without an origin check, an onMessageExternal listener without a sender check, and an exported Android component without a permission are unguarded findings already (Link, payload, and guards): each item is reported under one finding only. A sender with a concrete origin, a 0o600 socket, a pinned manifest and an Electron window with the secure defaults are not findings.

Every finding carries finding, severity, confidence, protocol, node, file, line, entry_points, detail and fingerprint. entry_points lists the entry kinds that reach the finding (kind, count, sample; at most five kinds, the total in entry_point_total). On a combined graph (cg link) each finding also names its repo. The fingerprint hashes finding, node id and file, never the line, so it survives edits above the finding.

An unguarded finding as it prints after part 2d, on the tests/surface_fixture apps (the /health route is public and not listed; the second route reads only):

$ cg surface --db out/graph.db --finding unguarded
attack surface: 2 finding(s) (1 medium, 1 low)
inbound surface: 9 item(s) (1 guarded, 1 public, 1 secret-checked, 4 unchecked, 2 unguarded); outbound surface: 6 system(s)

== unguarded (2) - Inbound handler without a guard ==
  http
    MEDIUM [resolved] route:ANY /admin/purge/
      ANY /admin/purge/ has no auth guard and reaches a write
      at py/bookshop/urls.py:7
      reached from http_route(1)
      fingerprint 4f95d4747c5550f1
    LOW [resolved] route:ANY /hooks/github-open/
      ANY /hooks/github-open/ has no auth guard
      at py/bookshop/urls.py:9
      reached from http_route(1)
      fingerprint 36091b3f583debb1

An unclear case adds its reason to the detail and has heuristic confidence, for example ANY /graphql/ has no auth guard (unclear: GraphQL endpoint: access is checked per field).

$ cg surface --db out/graph.db --finding hardcoded
attack surface: 2 finding(s) (2 high)
inbound surface: 7 item(s) (1 guarded, 1 secret-checked, 2 unchecked, 3 unguarded); outbound surface: 4 system(s)

== hardcoded (2) - Hardcoded credential ==
  postgres
    HIGH [exact] external:postgres:db.bookstore.example:5432
      literal password in code or configuration for db.bookstore.example:5432 (value not shown)
      at bookshop/settings.py:6
      reached from no entry point
      fingerprint 75454517a93c3363
  saas
    HIGH [exact] external:saas:postmark
      literal key in code or configuration for postmark (value not shown)
      at bookshop/notify.py:6
      reached from http_route(1)
      fingerprint 3412ee3e89b46e48

Disabled verification, insecure gRPC and IPC exposure on the test fixtures:

$ cg surface --db out/graph.db --finding tls-off
== tls-off (8) - TLS or SSH host verification disabled ==
  ssh
    HIGH [exact] function:bookshop.catalog.push_stock
      SSH host key not checked (AutoAddPolicy / WarningPolicy)
      at bookshop/catalog.py:40
      reached from no entry point
      fingerprint 765683799095b51d
  tls
    HIGH [resolved] function:bookshop.catalog.sync_catalog
      certificate verification is switched off (verify=False)
      at bookshop/catalog.py:9
      reached from no entry point
      fingerprint 4a56ab0842be36a3

$ cg surface --db out/graph.db --finding plaintext --protocol grpc
== plaintext (2) - Plaintext protocol ==
  grpc
    MEDIUM [resolved] function:bookshop.catalog.inventory_channel
      client channel without TLS: inventory.bookstore.example:50051
      at bookshop/catalog.py:54
      reached from no entry point
      fingerprint 718a42ac53b9e2ce

$ cg surface --db out/graph.db --finding ipc-exposed
== ipc-exposed (8) - IPC endpoint open to untrusted callers ==
  extension
    MEDIUM [exact] file:config:extension-open/manifest.json
      externally_connectable.matches accepts any web page (*://*/*)
      at extension-open/manifest.json:6
      reached from message_handler(1)
      fingerprint f9f6fad48a93744a
  unix
    MEDIUM [exact] endpoint:unix:/run/bookshop/agent-open.sock
      Unix socket /run/bookshop/agent-open.sock is chmod 0o666: any local user can connect
      at agent/sock.py:10
      reached from message_handler(1)
      fingerprint 113ead5ceff1f789

Options

optionmeaning
--inbound / --outboundlist that surface; findings are limited to its direction
--protocol Pone protocol (http, ws, grpc, tcp, redis, postgres, saas, ...)
--finding Fone finding type
--min-confidence resolveddrop heuristic findings and items (exact keeps only exact)
--format text|json|sarifjson is the full structure; sarif is SARIF 2.1.0
--fail-on f1,f2exit 1 when any listed finding remains after ignores
--max-items Ncap on listed findings and items (default 200); totals stay in the summary
--strictunguarded counts route guards only: no framework defaults, no handler checks, no public-route table, medium severity
--show-ignoredlist the findings surface.ignore accepted

SARIF has one rule per finding type (cg.surface.<finding>), level from severity (high error, medium warning), repo-relative locations, and the fingerprint in partialFingerprints. Ignored findings appear as results with a suppressions entry only with --show-ignored.

Gates

--fail-on exits 1 when a listed finding remains, 0 otherwise, 2 for an unknown name. It judges every finding type after ignores, --protocol and --min-confidence; --finding, --inbound and --outbound only change what is printed.

# GitHub Actions
- run: cg index . --db out/graph.db
- run: cg surface --db out/graph.db --format sarif > cg.sarif && cg surface --db out/graph.db --fail-on hardcoded,unverified
- uses: github/codeql-action/upload-sarif@v3
  if: always()
  with: { sarif_file: cg.sarif }

Ignores

An accepted risk goes in .cg.yaml. The reason is required; cg config validate rejects an entry without one.

surface:
  ignore:
    - {finding: unguarded, path: "app/Http/Controllers/HealthController.php", reason: public health probe}
    - {finding: plaintext, id: "external:redis:cache:6379", reason: compose-internal network}
    - {finding: hardcoded, fingerprint: 75454517a93c3363, reason: rotated test key}

finding is a finding type, plus one of path (a file, a directory or a glob, repo-relative), id (a node id or glob) or fingerprint. Every key given must match. The file is read again from the indexed root on each run, so an ignore applies without re-indexing. Ignored findings are counted in the summary and listed with --show-ignored. On a combined graph an ignore applies to the findings of the repo whose .cg.yaml holds it.

MCP

The attack_surface tool takes inbound, outbound, protocol, finding, min_confidence, strict, show_ignored and max_items. The reply is the text report; the structured reply has the full result under surface next to the completeness object (MCP server).

No secret values in the index

A credential literal is never kept in the graph database. At index time a literal under a key that looks like a credential (password, secret, api_key, passphrase, dsn, a token or key value, ...) is stored as redacted:hmac:<first 8 hex of HMAC-SHA-256(salt, value)>: it shows that a literal exists and tells two values apart within the graph, and it cannot be reversed. The salt is secrets.token_hex(16), created with the database and stored in the meta table under redact_salt; re-indexing and cg refresh keep it, a new database gets a new one. The salt is not secret from someone who holds the database, but a marker from a leaked screenshot, log or SARIF file cannot be matched against a table of common passwords without it, and the same password in two graphs gives two markers. Fingerprints are comparable inside one graph only: a cg link combined graph keeps the markers of its sources and has no salt of its own, and comparing a secret across repos is not supported. Indexes made before part 2d hold redacted:sha256:; both prefixes count as redacted, and re-indexing replaces the old form. Only the last word of the key (the last dotted segment, split on _, - and camelCase) decides: it is a credential when it is password, passwd, pwd, pass, passphrase, secret or dsn, when the name ends in an explicit compound (api_key, access_key, secret_key, private_key, signing_key, encryption_key, client_secret, webhook_secret, auth_token, api_token, access_token, refresh_token, bearer_token), or for Laravel's app.key / APP_KEY. A bare key or token as last word is redacted by default, as before. Its value is kept only when the word before it (a dotted segment counts) is one of primary, foreign, sort, partition, route, cache, idempotency, remember, csrf, xsrf, public, i18n, translation, lookup, unique, index, storage, prefix (primary_key, cache.key, remember_token), and the value never decides, because a token can have any shape (lowercase words, dotted, key- prefixed). services.ledger.token and LEDGER_TOKEN therefore stay redacted for any value that has a digit, an upper-case letter or a base64: form. AUTH_GUARD, AUTH_PASSWORD_BROKER and try_it_credentials_policy are not credentials, and *_PUBLIC_KEY, *_KEY_ID, *_KEY_PATH and *_KEY_FILE never are. This covers the Laravel config value and env('X', 'default') default attrs. A password inside any scheme://user:password@host string in a node or edge attr gets the same marker, so the host stays readable. Non-secret values ('currency' => 'EUR', a hostname default) are stored as before, and hardcoded still sees a marker as a literal. cg node config:... and a direct database read show the marker, never the value. The external-system attrs of #42 parts 3a / 3b and the Python and TypeScript config readers keep addresses, key names and locations only, so they have nothing to redact; a test indexes the surface fixtures and fails if a SURF- marker appears in any table. The doc text of a node is swept as well, so an example DSN in a comment keeps its host and loses its password. A map keyed by source identifiers (reexports) is left alone: an exported class named ApiKey is a name.

Validation

Counts, sampled true / false positives per finding type and recall on three intentionally vulnerable apps are in the validation log. unguarded is the noisiest type on large apps, and still needs review after part 2d (Known limitations).

Not detected yet

Numeric validation limits are not produced by this version (Known limitations). New detectors add a finding type; the shape above does not change.