Skip to content

feat(credentials): add credential inventory scanning - #186

Open
raysubham wants to merge 8 commits into
step-security:mainfrom
raysubham:feat/credential-inventory
Open

feat(credentials): add credential inventory scanning#186
raysubham wants to merge 8 commits into
step-security:mainfrom
raysubham:feat/credential-inventory

Conversation

@raysubham

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds a credential-location inventory phase: which developer tools on this machine hold credentials, where, and how well guarded each location is. It reports locations and protection, never the credential — no value, substring, digest, fingerprint, whole-file hash, or any category derived from the secret's characters reaches the wire, a row, a log, or an error.

Sixteen sources across cloud (AWS, GCP, Azure), source control (SSH keys, git credential store, gh), containers (Docker, kubeconfig), package registries (npm, PyPI), infrastructure (Vault, Terraform) and AI/MCP configuration.

New packages

internal/detector/credentials — exact catalog paths only, never a directory walk. Every read is byte-capped and the parse stops at the cap; a capped read is recorded as incomplete rather than as a clean file, so a credential sitting past the cap can never render as "read, empty, complete". Findings carry a tokenized location ($HOME / $APPDATA / $XDG_CONFIG_HOME / $ABS), protection state, mode, size, mtime and git context. Locations moved by an environment variable are resolved per user, and a variable that is set but unresolvable marks only the sources that read it — not the whole run.

internal/safepath — the resolver every read in the phase goes through:

  • per-component os.Lstat + os.Readlink rather than filepath.EvalSymlinks, so no resolution happens behind the guard's back
  • an openat chain with O_NOFOLLOW|O_DIRECTORY, and O_NONBLOCK on the leaf (a FIFO in a credential path cannot hang the phase)
  • the TCC consent guard consulted before each syscall, not after
  • containment against the OS user record, checked at every hop — a path that leaves the user's roots is refused before the components below it are stat'd, so ~/.aws -> /Volumes/network-share/config never touches the volume

internal/model/credentials.go — the wire types, plus a committed golden fixture and a strict round-trip test. The reader on the other end discards fields it does not know, so a renamed field would stop arriving silently rather than fail. The fixture exercises every protection state, every reason code, both host-report shapes, both GitHub-CLI vocabularies in full, and all four location roots.

Other changes

  • GitHub CLI hosts: authentication status, token storage and observed scopes per configured host. The tool's own verdict strings are translated to this inventory's vocabulary in one place rather than forwarded — they have changed spelling across releases, and a reader cannot group hosts by a value it does not know. gh output is never logged.
  • detector/mcp.go: DetectKnownUserConfigs returns the known user-level MCP config paths with no discovery and no directory walk, resolved against the developer's home and roaming profile rather than the service account's. The no-walk guarantee is in the signature so a caller cannot opt out of it by accident.
  • Wiring: scan + telemetry, a one-minute phase budget, and the Windows-native test job extended to cover the two new packages (both have Windows-only code — the security descriptor behind broad_read_allow_ace_present, the per-account registry environment read, and the reparse-point-safe leaf open).

A nil section means the phase did not run. A section with zero findings is the positive assertion that no known location holds a credential — and it replaces the stored inventory wholesale, so the pointer is passed through untouched rather than defaulted.

Cross-repo contract

The consumer side is already merged in agent-api. The payload from this branch — both the committed golden fixture and a live scan of a real machine — was validated through that repo's own validateCredentialScan with no decode failure and no rejection reason. Three items need the reader to move, and none of them block this:

  1. skipped_no_user is still rejected for an empty source_id; the code describes the run, not a source.
  2. broad_read_allow_ace_present is tri-state here (*bool) and collapses to bool,omitempty there, merging "evaluated, nothing broad" into "not evaluated".
  3. duration_ms is unmodelled there; it is carried by every scan section in this repo, so it stays.

Type of change

  • Bug fix
  • Enhancement
  • Documentation

Testing

  • Tested on macOS (version: 26.5.2)
  • Binary runs without errors: ./stepsecurity-dev-machine-guard --verbose
  • JSON output is valid: ./stepsecurity-dev-machine-guard --json | python3 -m json.tool
  • No secrets or credentials included
  • Lint passes: make lint — 0 issues
  • Tests pass: make test

Additional:

  • make smoke — 44/44
  • -race green on all seven touched packages; 153 top-level tests / 234 subtests across credentials, safepath and model
  • CGO_ENABLED=0 builds for all six targets (linux, darwin, windows × amd64, arm64) and GOOS=windows go vet ./... clean
  • fuzz targets on each parser
  • live scan of this machine: 13 findings, 0 errors, scan_complete=true

Note on the --verbose box: that path dispatches to enterprise telemetry and was short-circuited by the run gate on this box (offline_cache_skip) — it exited cleanly but did not execute the phases. The credential phase itself was exercised through --json, which is where the 13-finding result above comes from.

Related Issues

Comment thread internal/detector/credentials/catalog.go Fixed
Comment thread internal/detector/credentials/location.go Fixed
Comment thread internal/detector/credentials/parse_ssh.go Fixed
Comment thread internal/detector/credentials/probe.go Fixed
Comment thread internal/detector/credentials/probe.go Fixed
Comment thread internal/model/credentials.go Fixed
Comment thread internal/model/credentials.go Fixed
Comment thread internal/model/credentials.go Fixed
Comment thread internal/model/credentials.go Fixed
Comment thread internal/safepath/open_unix.go Fixed
@raysubham
raysubham force-pushed the feat/credential-inventory branch from 4a34d19 to cf3d5a3 Compare August 5, 2026 19:55
Comment thread internal/detector/credentials/catalog.go Fixed
Comment thread internal/detector/credentials/location.go Fixed
@raysubham raysubham changed the title feat(credentials): inventory where developer tools keep credentials feat(credentials): add credential scanning and inventory Aug 5, 2026
@raysubham raysubham changed the title feat(credentials): add credential scanning and inventory feat(credentials): add credential inventory scanning Aug 5, 2026
Adds a credential-location inventory phase: which developer tools on this
machine hold credentials, where, and how well guarded each location is. It
reports locations and protection, never the credential — no value, substring,
digest, fingerprint, whole-file hash, or any category derived from the secret's
characters reaches the wire, a row, a log, or an error.

- internal/detector/credentials (new): sixteen sources across cloud, source
  control, containers, package registries, infrastructure and AI/MCP config.
  Exact catalog paths only, never a directory walk; every read byte-capped and
  the parse stops at the cap; a capped read is recorded as incomplete rather
  than as a clean file. Findings carry a tokenized location ($HOME/$APPDATA/
  $XDG_CONFIG_HOME/$ABS), protection state, mode, size, mtime and git context.
  Locations moved by an environment variable are resolved per user, and a
  variable that is set but unresolvable marks only the sources that read it.

- internal/safepath (new): the resolver every read in the phase goes through.
  Per-component os.Lstat plus os.Readlink rather than EvalSymlinks, an openat
  chain with O_NOFOLLOW|O_DIRECTORY and O_NONBLOCK on the leaf, the consent
  guard consulted before each syscall, and containment against the OS user
  record checked at every hop — so a path that leaves the user's roots is
  refused before the components below it are stat'd.

- internal/model/credentials.go (new): the wire types, with a committed golden
  fixture and a strict round-trip test. The reader on the other end discards
  fields it does not know, so a renamed field would stop arriving silently
  rather than fail; the fixture exercises every protection state, every reason
  code, both host-report shapes, both authentication and storage vocabularies
  in full, and all four location roots.

- GitHub CLI hosts: authentication status, token storage and observed scopes
  per configured host. The tool's own verdict strings are translated to this
  inventory's vocabulary in one place rather than forwarded, and gh output is
  never logged.

- detector/mcp.go: DetectKnownUserConfigs returns the known user-level MCP
  config paths with no discovery and no directory walk, resolved against the
  developer's home and roaming profile rather than the service account's.

- scan + telemetry wiring, a one-minute phase budget, and the Windows-native
  test job extended to cover the two new packages.

A nil section means the phase did not run; a section with zero findings is the
positive assertion that no known location holds a credential, so the pointer is
passed through untouched rather than defaulted.
The job covers three packages, not only devicepolicy, so the identifier
now matches the display name it already carried. No dependent job or
required status check references the old identifier.
None of the table-driven format parsers can report a protected
credential; that verdict comes only from key classification, which
asserts through its own table. The shorthand therefore had no caller and
tripped the unused check.
An environment override was expanded ahead of the catalog defaults instead of
in place of them, and every one of these tools stops reading its default path
once the variable is set. Two ways that surfaced on a live host: an override
naming a real file reported that file and the default, and an override naming a
path that does not exist fell through to the default entirely, because stopping
at the first match keyed off the first candidate that produced a finding and an
absent one produces none. Both are a row naming a real file at a real path that
its tool has stopped opening.

Take the first override that is set, in declaration order, expand it and stop:
no later override and no catalog default, whatever the expansion came to. Being
set is what displaces the default, not naming somewhere real. kubectl consults
its own default only when KUBECONFIG is empty; any non-empty value is split and
its empty elements are then dropped without the default coming back, so a value
of nothing but separators names nowhere to look. A missing target is likewise
the answer rather than a fallback, and neither is an error, because nothing
failed. Only an override kind with no expansion stands aside for the default,
where an over-report is the better way for an inventory to be wrong.

Tokenisation also learns the filesystem's spelling of the home, which
containment already admits: a home reached through a symlink is still the home,
and giving its resolved form the opaque root hands one file two identities and
hides the only root a reader can interpret. It is applied by respelling the path
onto the account record's home and matching the roots again, not by carrying the
resolved home as a second root. As a root it would match at the home and stop,
taking every path below it away from the more specific roots that live there, so
a redirected profile's roaming file would come back under the home token. A
configuration directory spelled with the resolved home but otherwise at its
default is the default, not a move, and keeps the home token too.

Two claims are corrected rather than implemented. The bounded key read does not
keep key material out of the process: for a file smaller than the cap the body
is joined and decoded whole, private section included, so the comments now state
the guarantee that holds, which is that nothing read is serialised, logged,
fingerprinted, counted or retained past the classification. Lowering the cap
does not recover the stronger claim either, since the hardware-key check reads
past a public key an RSA modulus does not fit inside. And the consent test is
renamed to say what it covers: which resolver each half of the delegated source
reaches, asserted in-process against a temporary tree, which is not evidence
about a scheduled run under a system account with no session to prompt in.
@raysubham
raysubham force-pushed the feat/credential-inventory branch from 8d4f375 to 81ae6d7 Compare August 6, 2026 14:14
Two are conversions gosec cannot see are bounded, and both read better
without the conversion: the SSH length check now compares in int64,
which a uint32 length and a slice length both widen into without
wrapping, and the Windows final-path call passes its buffer size as a
constant rather than converting the slice length back.

The rest are annotated where they sit. Seven are the identifier-name
heuristic firing on constants whose names carry "credential" or "token"
and whose literals are wire vocabulary: source identifiers, a path root,
reason and outcome codes, and the CLI's own word for where it keeps a
token. One is the probe's subprocess, whose program is the path a PATH
lookup returned for the CLI or the literal sudo, and whose arguments are
package constants plus that path and the enumerated account name. The
last is the descriptor openat returned, which the success path this line
sits on has already established is non-negative.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new credential-location inventory capability to Dev Machine Guard, including a hardened file-open/resolution layer and new wire types, then wires the resulting snapshot into both community scan output and enterprise telemetry.

Changes:

  • Introduces internal/detector/credentials to inventory known credential locations (fixed paths + bounded reads) and optionally enrich GitHub CLI host entries.
  • Adds internal/safepath to safely resolve/open paths through verified components with root containment checks (and platform-specific open verification).
  • Wires the new scan section into scan and telemetry, adds a phase budget, extends model JSON-shape tests, and updates CI to run Windows-native tests for the new Windows-specific code paths.

Reviewed changes

Copilot reviewed 31 out of 31 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/telemetry/telemetry.go Runs the new credentials_scan phase and includes credential_scan in the enterprise telemetry payload.
internal/telemetry/phase_deadline.go Adds a 1-minute budget for the new credentials_scan phase.
internal/scan/scanner.go Runs credential inventory in community-mode scans and attaches it to model.ScanResult.
internal/safepath/safepath.go New safe path resolver that resolves per-component and enforces containment; provides bounded reads and bounded directory listings.
internal/safepath/open_unix.go Unix openat-chain implementation to prevent symlink-swapped component traversal.
internal/safepath/open_windows.go Windows handle-based open verification using FILE_FLAG_OPEN_REPARSE_POINT + GetFinalPathNameByHandle.
internal/safepath/safepath_test.go Unit tests covering containment, guard behavior, truncation semantics, and open verification behavior.
internal/model/model.go Adds CredentialScan *CredentialScanInfo to ScanResult with omitempty semantics.
internal/model/credentials.go New credential inventory wire types and closed vocabularies (categories, protection states, reason codes, GitHub host report enums).
internal/model/testdata/credential_scan_golden.json Golden credential-scan payload fixture exercising the schema and vocabularies.
internal/model/credentials_golden_test.go Golden round-trip + vocabulary coverage tests for credential-scan payloads.
internal/model/scanresult_jsonshape_test.go Ensures credential_scan is omitted when nil (distinguishes “did not run” vs “ran and found nothing”).
internal/detector/mcp.go Adds DetectKnownUserConfigs to return known user-level MCP config paths without discovery/walk.
internal/detector/credentials/catalog.go Credential-source catalog (fixed locations, relocation overrides, caps, and matching policy).
internal/detector/credentials/detector.go Orchestrates per-source collection, applies safepath, enforces caps, and builds findings/errors.
internal/detector/credentials/location.go Path tokenization, relocation handling, git repo/tracked checks, and Windows SDDL parsing helpers.
internal/detector/credentials/parse.go INI-ish parsers and core “observation” folding logic for protection/count inference.
internal/detector/credentials/parse_doc.go JSON/TOML/YAML/JSONC parsing for Docker/Terraform/Kubeconfig/MCP/GCP/GH config shapes.
internal/detector/credentials/parse_ssh.go SSH private key classification (OpenSSH/PKCS8/legacy/PuTTY) with header-only reading.
internal/detector/credentials/probe.go GitHub CLI auth status --json hosts probe runner + parsing and env-probe helpers.
internal/detector/credentials/*_test.go Unit/fuzz tests for parsers, token/env fences, and Windows-specific behavior.
.github/workflows/tests.yml Extends Windows-native CI job to run tests for devicepolicy, detector/credentials, and safepath.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/detector/credentials/probe.go
Comment thread internal/detector/credentials/detector.go
The probe builds its own exec.Cmd because it needs three things the shared
executor cannot express: standard error discarded at the pipe rather than
returned as a string, standard output collected through a bounded buffer,
and an environment with the CLI's token variables removed. What it was
missing is orthogonal to all three -- console-window suppression on Windows
and a cancellation that reaches the process group, so a CLI left running
under sudo does not survive the timeout.

Those two are now HardenCommand in the executor package, which is what its
own Run and RunInDir already did inline. The probe calls it and keeps
building the rest of the command itself. Its wait delay stays stated
locally: the group teardown is a no-op on Windows, and the bound the phase
depends on has to hold on every platform.

Also documents why a skipped_no_user error carries no source id. Nothing
was attempted when no account resolves, so there is no source to name, and
a value invented for the field would look like a catalog source and match
none. The reader accepts an empty source only for this reason code, so the
shape is the contract rather than an omission.
Comment thread internal/executor/executor.go Fixed
Folding Run and RunInDir onto the new helper edited the lines directly
above their exec.CommandContext calls, and the subprocess findings already
standing against those calls were re-reported as this branch's on the
strength of that. The findings are the executor's own and predate this
work; silencing them here would be suppressing a live one to unblock an
unrelated change.

So the helper moves to its own file and the two call sites keep their
inline pair. Nothing in executor.go changes, which is what keeps its
findings attributed where they belong. Folding those call sites in is
worth doing when that file is being changed for its own reasons.
A file holding one function is not worth the name. The helper belongs with
the other command construction in this package, and it goes at the end so
the file's existing lines keep their positions and the subprocess findings
already standing against them stay attributed where they belong.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants