Skip to content

Structural secret redaction - #191

Open
Liam-Doodson wants to merge 38 commits into
mainfrom
fix/secret-redaction
Open

Structural secret redaction#191
Liam-Doodson wants to merge 38 commits into
mainfrom
fix/secret-redaction

Conversation

@Liam-Doodson

@Liam-Doodson Liam-Doodson commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds common/redact: a Secret string-wrapper type (masked String()/MarshalJSON(), explicit Reveal()), a SafeFlags allow-list built from this CLI's actual registered flags, and Args() to redact an argv-shaped slice.
  • Redacts CLI args before they can reach panic/error output. A permanent regression guard (common/redact/argscope_test.go) initially caught 13 raw os.Args reads across response.go's panic branches, a second neo4j-cli/main.go entrypoint, and one corspolicy leak — all now fixed via two sanctioned capture points (one per CLI binary entrypoint), with the guard green.
  • AuraCredential.ClientSecret is now a redact.Secret; credential list output is masked while the on-disk credentials file still stores the real value.
  • One changie fragment describing the change without naming trigger conditions.

…and SafeFlags

Build the common/redact package with Secret (a string wrapper with masked
String/MarshalJSON and Reveal for actual values) and SafeFlags allow-list to
classify CLI flags as safe-to-echo or unsafe. Include Args() function to mask
values following unsafe flags, with comprehensive table-driven test coverage for
Secret masking and Args flag value redaction across multiple flag types.
Add permanent regression guard test at common/redact/argscope_test.go that walks
the repository's Go source tree and fails if raw os.Args[1:] reads appear outside
sanctioned locations. Currently fails as expected, naming specific files/lines
where the unredacted os.Args[1:] leaks are found (Finding 01). Will turn green
as a side-effect of task-003 when the sanctioned capture point is introduced in
neo4j-cli/aura/cmd/main.go. Test output demonstrates the guard catches violations
at the code level with precise file and line numbers.
…Secret

This change wraps the ClientSecret field with the redact.Secret type
to ensure secrets are automatically masked in JSON output and display
paths, while remaining unredacted in on-disk storage.

Changes:
- Changed AuraCredential.ClientSecret type from string to redact.Secret
- Implemented custom UnmarshalJSON on AuraCredential to wrap loaded secrets
- Modified Credentials.save() to reveal secrets when writing to disk
- Updated token.go to call .Reveal() when building HTTP auth requests
- Created new list_test.go promoted from audit finding F05
- Updated add_test.go and instance/get_test.go for field ordering

The on-disk credentials file still stores the real, unredacted secret,
while json.Encode calls (via Print) automatically mask it through
Secret.MarshalJSON(). No changes needed to the Print() method itself.

Test results:
- new TestCredentialListMasksClientSecret: shows "****" in JSON output
- new TestCredentialListFileStillContainsRealSecret: file has real value
- all credential, clicfg, and redact package tests pass
…/error output

This commit implements task-003 of the secret-redaction feature:

- Promote audit test f01_audit_test.go to response_test.go, updated with working redacted-args implementation
- Add package-level RedactedArgs variable in api package to store redacted command-line arguments
- Introduce sanctioned capture points in both main() functions (neo4j-cli/aura/cmd/main.go and neo4j-cli/main.go)
- Replace all 13 raw os.Args[1:] reads with RedactedArgs:
  - 11 panic/error branches in response.go
  - 1 in corspolicy/allowedorigin/utils.go
  - 2 in main() recover blocks (already captured as redactedArgs variables)
- Update argscope guard test to include both main.go files as sanctioned locations
- All tests pass including the guard regression test

The test asserts that command-line secrets do not leak into panic messages even when
unrecognized HTTP status codes are encountered.
…vert untyped-map save() hack

Replace the unsafe hand-built map[string]interface{} approach in credentials.save() with typed struct marshaling. This fixes a regression where new fields added to AuraCredential would silently vanish from the on-disk file.

Changes:
- Add private auraCredentialsOnDisk and auraCredentialOnDisk structs for on-disk representation
- Modify save() to use typed marshaling via auraCredentialsOnDisk
- Modify load() to unmarshal into typed struct then convert to in-memory format
- Delete AuraCredential.UnmarshalJSON - loading now works through struct tags
- Add conversion methods (toOnDisk, toAuraCredentials) to handle the round-trip
- Restore add_test.go to whole-file JSON assertions
- Add comprehensive test suite verifying typed structure and round-trip behavior

The on-disk file still contains the real, unredacted secrets, while display paths mask them via Secret.MarshalJSON(). The typed approach ensures field-to-key mapping lives in one place (Go struct tags) instead of three.
…edacted-args capture

Addresses three related issues found in the security audit:

1. Fix response_test.go: The test was vacuous because it set os.Args directly
   while response.go reads from api.RedactedArgs (which was nil in tests).
   Updated test to set redacted args the same way main() does, and assert
   the mask (**** ) appears alongside safe tokens, not just that the raw
   secret is absent.

2. Collapse response.go's 11 near-identical panic/error strings into a single
   unexpectedStatusError() helper that is the sole reader of the redacted-args
   value. This eliminates duplication while preserving the exact message text.

3. Relocate the redacted-args capture point from api.RedactedArgs/
   aura.SetRedactedArgs to common/redact package (new capture.go file).
   Both main() functions and corspolicy/allowedorigin/utils.go now read
   from redact.CapturedArgs() directly, eliminating the "two routes to the
   same variable" issue.

- Create common/redact/capture.go with SetCapturedArgs/CapturedArgs functions
- Update both neo4j-cli main.go files to call redact.SetCapturedArgs
- Update utils.go to read from redact package
- Delete aura.SetRedactedArgs and api.RedactedArgs
- Simplify response.go with unexpectedStatusError helper
- Improve response_test.go to assert redaction is actually working

Sanctioned os.Args capture points remain unchanged (still two files).
…trim argscope_test.go

Fix three accuracy/quality issues in the common/redact package:

1. Args() masking now correctly masks unsafe flag values that start with '-'
   (e.g., generated secrets like '-abc123'). Previously, values beginning
   with '-' were incorrectly treated as flags and left unmasked.

2. SafeFlags is rebuilt to match actual CLI registered flags: changed 'dbid'
   to 'db-id', removed non-existent flags ('id', 'format', 'credential-id',
   'origin'), and added missing safe flags (organization-id, deployment-id,
   key-id, server-id, source-instance-id, source-snapshot-id, import-type,
   description, date, ttl, import-model-id). Made SafeFlags unexported
   (safeFlags) since it's only used internally by Args().

3. Broadened argscope_test.go's regex pattern from '\bos\.Args\s*\[' to
   '\bos\.Args\b' to catch any raw os.Args reference (not just indexed
   access), including direct assignments and function arguments. Trimmed
   the file from 153 to 131 lines by removing unnecessary comments and
   helper functions while preserving the exclusion-list mechanism.
…branch

Remove comments that merely restate what adjacent code already says via
its name and signature. Keep comments that explain non-obvious constraints,
invariants, or WHY decisions were made. Also remove task/process references
from comments. No behavioral changes; only comments and blank lines modified.
…k representation

Replace hand-maintained auraCredentialOnDisk mirror with embedding approach.
auraCredentialOnDisk now embeds *AuraCredential and shadows only the
ClientSecret field (as plain string for on-disk storage). This ensures
new fields added to AuraCredential are automatically preserved in the
on-disk JSON representation without requiring manual edits to the mirror
struct. Conversion methods toOnDisk/toAuraCredentials now handle only the
shadowed field. Added MarshalJSON for auraCredentialOnDisk to ensure
correct JSON field ordering. Updated tests to verify embedding preserves
all struct fields via reflection.
…e-blob JSON assertion

Restore the strict whole-file JSON blob comparison in
TestUnauthorizedAccessTokenRefresh that was weakened during task-004
to tolerate the untyped-map save() hack's non-deterministic key
ordering. Task-007 reverted that hack to a typed representation,
restoring deterministic key order, making this assertion valid again.
This is a pure test-quality fix with no production code change.
…an flags

Fixed regression from task-009 where Args() was unconditionally masking the
token after any unsafe flag, including boolean flags that don't take values.
Now distinguishes between flags that take values and boolean flags using an
explicit set of known boolean flags. For commands like
"data-api graphql create --enabled --name my-api", the flag name "--name"
is no longer incorrectly masked.

- Added booleanFlags map identifying flags that don't consume next argument
- Updated Args() condition to skip value consumption for boolean flags
- Added test case for unsafe boolean flag followed by safe flag with value
- Verified existing dash-prefixed secret case from task-009 still passes
fmt.Sprintf("%s", resBody) on a []byte is a no-op conversion; use
string(resBody) directly. Flagged by CI's staticcheck run on the draft PR.
Leftover from an earlier UnmarshalJSON approach, no longer referenced
after task-012's embedding rewrite. Flagged by staticcheck (U1000).
Whitespace/formatting only, no logic change.
Third audit pass found the embedding fix (task-012) traded the mirror-struct
field-drop risk for two new problems: a nil-pointer panic when loading a
credential object missing every promoted field, and a custom MarshalJSON
that reintroduced a hand-copied field list on the write path (making the
comment claiming automatic field preservation false again).

- Delete auraCredentialOnDisk.MarshalJSON entirely; it existed only for
  cosmetic key ordering. Removing it removes the second hand-copied list
  and makes the existing comment accurate again (trimmed for the no-comments
  policy regardless, since a comment asserting a guarantee is a liability).
- Fix toAuraCredentials to handle a nil embedded *AuraCredential instead of
  dereferencing it, with a regression test loading a partial credential
  object.
- Update the two credential-list JSON test assertions and one
  get_test.go assertion for the new (default, unforced) key order.

Also addresses the second pass's non-blocking findings while in this code:
- booleanFlags (task-010): removed a phantom "show-progress" entry, added
  the two flags cobra registers automatically (help, version).
- Deleted a vacuous assertion in TestArgsPreservesStructure and fixed a
  stale test-case name left over from before task-010.
- unexpectedStatusError's variadic-as-optional body param split into a
  plain no-body function and a WithBody variant.
- Removed leftover WHAT-comments in list_test.go, a redundant import
  alias in both main.go entrypoints, an assert.True/strings.Contains
  where assert.Contains already reads better one line up, and a test
  name/comment referencing a since-removed triage artifact.
Two more comments asserting guarantees the code didn't hold (the exact
class of issue that motivated the branch's comment policy):
- TestOnDiskJSONStructure's comment claimed new fields are "preserved"
  when the test's hardcoded allowlist actually fails on any unrecognised
  field. Consolidated it with TestEmbeddingPreservesNewFields (which had
  it right, via reflection) into one test; deleted TestConversionMethods
  as redundant with TestRoundTripSaveAndLoad.
- redact.go's safeFlags comment claimed "any flag not in this map is
  masked" — false since booleanFlags entries are never masked either
  (a token after a bool flag is positional, correctly left alone).
  Removed the comment rather than trying to describe both maps' interplay.

Also, per the verified-safe simplification the audit found: embed
AuraCredential by value instead of by pointer in auraCredentialOnDisk.
Nothing needed the pointer (toOnDisk builds fresh, toAuraCredentials
copies out), so this removes the nil-guard branch entirely rather than
just testing it — the "partial object" case is now a zero-valued struct,
not a nil pointer that must be checked.

Smaller fixes: removed "version" from booleanFlags (it takes a value on
`instance create`; only safe by accident since it's also in safeFlags).
Removed a redundant guard in argscope_test.go's comment-stripping (fully
subsumed by the truncation two lines below). Collapsed the local
redactedArgs variable in both main() entrypoints into one CaptureArgs
call read back via CapturedArgs(), same as every other call site.
capture.go's comment asserted "set by main() at the top of each CLI
entrypoint" - already loose (CaptureArgs is also called from tests) and
unenforced, the same shape as the comments removed in the prior pass.
Also dropped a one-line WHAT-restatement in redact_test.go.
…s to MaskArgs

Part 1: Treat AuraCredential.AccessToken as a redact.Secret type to mask it in
display output (credential list) while preserving the real token value on disk and
in authorization contexts. Updated AccessToken type in both the runtime
AuraCredential struct and the on-disk auraCredentialOnDisk mirror, with proper
Reveal() calls where the real token is needed (getToken, HasValidAccessToken).
Extended existing credential list tests with access-token masking cases.

Part 2: Renamed redact.Args() to redact.MaskArgs() for clarity (the function name
alone now conveys it masks arguments). Updated the single internal call site in
CaptureArgs and renamed all test functions consistently (TestArgs → TestMaskArgs,
TestArgsMasksSecrets → TestMaskArgsMasksSecrets, TestArgsPreservesStructure →
TestMaskArgsPreservesStructure).

Acceptance criteria met:
- credential list's JSON output masks access-token with "****"
- On-disk credentials file stores the real access token in plaintext
- HasValidAccessToken correctly compares revealed token against empty string
- MaskArgs function renamed throughout common/redact with no behavior change
- go build/vet/gofmt/staticcheck/test all pass
Comment thread neo4j-cli/aura/internal/api/response.go Outdated
Comment thread common/redact/redact.go Outdated
Comment thread common/redact/argscope_test.go
Comment thread common/clicfg/credentials/credentials_test.go
…or messages

Update all "please report an issue" messages that incorrectly pointed to
https://github.com/neo4j/cli to use the correct repository URL
https://github.com/neo4j/aura-cli instead. This fix applies to all 7
occurrences across error/panic messages in main entry points and error
handlers.
Refactor MaskArgs in common/redact/redact.go to improve readability by:
- Extracting flag parsing logic into a separate parseFlagFromArg function
  that handles both --flag value and --flag=value forms
- Improving clarity of the main loop structure

Also fix three t.Errorf messages in common/redact/redact_test.go that
still referred to Args() instead of MaskArgs() after the earlier rename.
…assert

Convert argscope_test.go and credentials_test.go from manual t.Errorf/t.Fatalf
checks to github.com/stretchr/testify/assert equivalents, matching the
assertion style used throughout the rest of this branch's test files.
…y conversion

task-019 converted manual t.Fatalf/t.Fatal checks to testify/assert in two test
files, but several of these checks are preconditions that later code in the same
test depends on. assert.* logs a failure and continues execution, unlike t.Fatalf
which halts immediately via runtime.Goexit(). This turned clean test failures into
actual hangs and panics:

- findRepoRoot's loop termination guard (assert.NotEqual checking parent == current)
  would spin forever if the assertion fired
- TestOnDiskJSONStructure's chain of type-assertion guards would continue with nil
  values, causing index-out-of-range panics

Fix by changing these specific precondition-guarding checks from assert.* to
require.* (same github.com/stretchr/testify/require package, which calls
t.FailNow() to stop execution). Leave genuinely final assertions as assert.*
since nothing downstream depends on their checked values.

- common/redact/argscope_test.go: findRepoRoot's two precondition checks
- common/clicfg/credentials/credentials_test.go: TestOnDiskJSONStructure's
  four type-assertion checks that guard subsequent indexing/use
…kJSONStructure

task-020's fix missed one: the "expected 1 credential" length check still
guards the very next line's credentialsData[0] index. Same bug class -
assert.Equal doesn't halt on failure, so an empty credentialsData would
panic on the index rather than failing cleanly. Changed to require.Equal.
…unction

Refactored MaskArgs() to extract flag-processing logic into a single maskArg() helper
that returns both output tokens and how many additional args were consumed. This eliminates
three readability issues: the index-based loop with in-place mutation (result[len(result)-1]),
the compound boolean condition, and the nesting in the main flag-handling logic. The main
loop is now a simple for-i with append and advance-by-extraConsumed, while maskArg uses
early returns for each case (non-flag, inline value, boolean flag, no next arg, safe+flag-like,
safe+value, unsafe+value). No behavioral change; all existing tests pass unchanged.
The action's version input defaults to "latest" when unset, so CI was
resolving go install honnef.co/go/tools/cmd/staticcheck@latest against
whatever Go "stable" happened to be at that moment. The two floating
versions drifted out of sync, producing a staticcheck internal error
decoding newer Go export data it doesn't yet support. Pin to 2026.1,
verified locally against the actual toolchain in use.
The prior refactor still iterated via for i := 0; i < len(args); i++,
which was the specific pattern flagged as hard to follow. Rewritten so
maskArg operates on the remaining slice's front element and reports how
many leading elements it consumed; MaskArgs shrinks remaining by that
amount each iteration instead of tracking an index.
go-version: "stable" resolved to Go 1.27.0 on the runner, a release
newer than staticcheck 2026.1's bundled export-data parser supports
(format version 4 vs. max supported 2) -- the actual source of the
CI staticcheck failure, not the staticcheck version itself. Pin to
1.26.2, the combination verified locally against staticcheck 2026.1.
Matches the style already used elsewhere on this branch (argscope_test.go,
credentials_test.go). Precondition-guarding checks that later code in the
same test depends on (element-count checks before indexing, marshal/unmarshal
errors before using the result, type assertions before using the asserted
value) use require so a failure halts instead of continuing into a panic.
Comment thread .github/workflows/test.yml Outdated
@Liam-Doodson
Liam-Doodson marked this pull request as ready for review August 20, 2026 10:51
@Liam-Doodson
Liam-Doodson requested a review from a team as a code owner August 20, 2026 10:51
@Liam-Doodson
Liam-Doodson requested a review from mjfwebb August 20, 2026 10:51
@Liam-Doodson Liam-Doodson changed the title Structural secret redaction (Findings 01 and 05) Structural secret redaction Aug 20, 2026
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.

1 participant