Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.33.3] - 2026-07-23

### Fixed

- The credential detector no longer flags source-code identifiers — lowercase assignments, method calls, dotted references, struct-tag keywords, and versioned `arg_`/`opt_` names — as secrets, while values carrying deterministic credential evidence (high entropy, credential markers) are still detected.

## [0.33.2] - 2026-07-23

### Fixed
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Pastewatch
[![Stable](https://img.shields.io/badge/status-stable-brightgreen)](https://github.com/ppiankov/pastewatch/releases)
[![Version](https://img.shields.io/badge/version-0.33.2-blue)](https://github.com/ppiankov/pastewatch/releases/tag/v0.33.2)
[![Version](https://img.shields.io/badge/version-0.33.3-blue)](https://github.com/ppiankov/pastewatch/releases/tag/v0.33.3)
[![License: MIT](https://img.shields.io/badge/license-MIT-yellow)](LICENSE)
[![CI](https://github.com/ppiankov/pastewatch/actions/workflows/ci.yml/badge.svg)](https://github.com/ppiankov/pastewatch/actions/workflows/ci.yml)
[![ANCC](https://img.shields.io/badge/ANCC-compliant-brightgreen)](https://ancc.dev)
Expand Down Expand Up @@ -790,7 +790,7 @@ Works with any comment style (`#`, `//`, `/* */`).
# .pre-commit-config.yaml
repos:
- repo: https://github.com/ppiankov/pastewatch
rev: v0.33.2
rev: v0.33.3
hooks:
- id: pastewatch
```
Expand Down Expand Up @@ -1005,7 +1005,7 @@ Do not pretend it guarantees compliance or safety.

## Project Status

**Status: Stable, feature-complete** · **v0.33.2** · Accepting compatibility and bug fixes only
**Status: Stable, feature-complete** · **v0.33.3** · Accepting compatibility and bug fixes only

| Milestone | Status |
|-----------|--------|
Expand Down
58 changes: 48 additions & 10 deletions Sources/PastewatchCore/DetectionRules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1164,27 +1164,65 @@ public struct DetectionRules {
let value = String(fullMatch[separatorRange.upperBound...])
.trimmingCharacters(in: .whitespaces)

// WO-390: Go struct field labels such as Token: makeToken() are code
// references, not literal credential values.
// WO-390@v2: source assignments and schema labels can carry code references,
// while values with deterministic secret evidence must remain detectable.
if isLikelyStructFieldReference(key: key, separator: separator, value: value) {
return false
}

return isValidCredentialValue(value)
}

// WO-390@v2: classify source-language references by value shape, not key casing.
private static func isLikelyStructFieldReference(key: String, separator: String, value: String) -> Bool {
let separatorText = separator.trimmingCharacters(in: .whitespaces)
guard separatorText == ":" else { return false }
guard let firstScalar = key.unicodeScalars.first,
CharacterSet.uppercaseLetters.contains(firstScalar) else {
return false
}
let cleanedValue = value.trimmingCharacters(in: CharacterSet.whitespaces.union(CharacterSet(charactersIn: ",")))
guard [":", "=", ":="].contains(separatorText), !key.isEmpty else { return false }

let trailingSyntax = CharacterSet.whitespacesAndNewlines
.union(CharacterSet(charactersIn: ",;\"'`"))
let cleanedValue = value.trimmingCharacters(in: trailingSyntax)
let identifierPattern = #"^[A-Za-z_][A-Za-z0-9_]*$"#
let dottedIdentifierPattern = #"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$"#
let callPattern = #"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\([^)]*\)$"#
return cleanedValue.range(of: identifierPattern, options: .regularExpression) != nil ||
cleanedValue.range(of: callPattern, options: .regularExpression) != nil

if cleanedValue.range(of: callPattern, options: .regularExpression) != nil ||
cleanedValue.range(of: dottedIdentifierPattern, options: .regularExpression) != nil {
return true
}

guard cleanedValue.range(of: identifierPattern, options: .regularExpression) != nil else {
return false
}
return !hasDeterministicCredentialEvidence(cleanedValue)
}

// WO-390@v2: preserve generic credential findings only when the value itself
// carries deterministic evidence beyond being a source-language identifier.
private static func hasDeterministicCredentialEvidence(_ value: String) -> Bool {
let lowerValue = value.lowercased()
// WO-390@v2: high entropy remains credential evidence regardless of identifier prefix.
if value.count >= minimumEntropyLength && shannonEntropy(value) >= entropyThreshold {
return true
}

// WO-390@v2: parser option/local names remain code identifiers even when versioned.
let sourceIdentifierPrefixes = ["arg_", "args_", "opt_", "opts_", "option_", "options_"]
if sourceIdentifierPrefixes.contains(where: lowerValue.hasPrefix) {
return false
}

let hasDigit = value.contains(where: \.isNumber)
if hasDigit {
return true
}

let hasUnderscore = value.contains("_")
let credentialMarkers = ["password", "passwd", "secret", "token", "api_key", "apikey"]
if hasUnderscore && credentialMarkers.contains(where: lowerValue.contains) {
return true
}

return false
}

/// Check if a key name (from JSON/YAML/properties) indicates a credential.
Expand Down
4 changes: 2 additions & 2 deletions Sources/PastewatchCore/Version.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
public enum AppVersion {
// WO-528@v2: 0.33.2 packages the guard and release hardening fixes.
public static let current = "0.33.2"
// WO-528@v2: 0.33.3 packages the guard and release hardening fixes.
public static let current = "0.33.3"
}
64 changes: 64 additions & 0 deletions Tests/PastewatchTests/DetectionRulesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,70 @@ final class DetectionRulesTests: XCTestCase {
}
}

// WO-390@v2: lowercase Go and Python assignments can carry code references, not secrets.
func testIgnoresLowercaseCodeReferenceAssignmentsAsCredentials() {
let codeReferences = [
"token = parse()",
"auth_token = args.small_p95_slo_seconds",
"api_key = options.apiKey",
"credentials := requestCredentials()",
"secret = computedValue",
"token = arg_small_p95",
"secret = opt_v2",
]

for input in codeReferences {
let matches = DetectionRules.scan(input, config: config)
let credMatches = matches.filter { $0.type == .credential }
XCTAssertEqual(credMatches.count, 0, "Should not detect code reference: \(input)")
}
}

// WO-390@v2: schema instructions ending in an operator keyword are prose, not credentials.
func testIgnoresStructTagCredentialKeywords() {
let structTags = [
#"`jsonschema:"description=confirmation token: MERGE"`"#,
#"`jsonschema:"description=confirmation secret: DELETE"`"#,
]

for input in structTags {
let matches = DetectionRules.scan(input, config: config)
let credMatches = matches.filter { $0.type == .credential }
XCTAssertEqual(credMatches.count, 0, "Should not detect struct-tag instruction: \(input)")
}
}

// WO-390@v2: code-reference exclusions must not hide deterministic secret evidence.
func testCodeReferenceExclusionsPreserveRealSecretShapes() {
let password = "password=" + ["s3cret", "value", "123"].joined(separator: "_")
let apiKey = "api_key=sk_live_" + String(repeating: "A1", count: 12)
let token = "token=TokenValue" + String(repeating: "A1", count: 8)
let prefixedToken = "token=arg_" + ["A1b2", "C3d4", "E5f6", "G7h8", "J9k0", "LmNp", "QrSt", "UvWx"].joined()
let userInfo = ["user", "pass"].joined(separator: ":")
let dsn = "dsn=postgres://" + userInfo + "@db-primary.internal/app"
let privateKey = "private_key=\n" + pemFixture(
label: "PRIVATE KEY",
payload: String(repeating: "QUJD", count: 12),
newline: "\n"
)
let cases: [(String, SensitiveDataType)] = [
(password, .credential),
(apiKey, .genericApiKey),
(token, .credential),
(prefixedToken, .credential),
(dsn, .dbConnectionString),
(privateKey, .sshPrivateKey),
]

for (content, expectedType) in cases {
let matches = DetectionRules.scan(content, config: config)
XCTAssertTrue(
matches.contains { $0.type == expectedType },
"Should preserve \(expectedType.rawValue) detection"
)
}
}

func testIgnoresStandaloneFortyCharStrings() {
// Go test function names, git SHAs, markdown paths — should NOT match AWS key
let falsePositives = [
Expand Down
2 changes: 1 addition & 1 deletion docs/agent-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ pastewatch-cli hook install
# .pre-commit-config.yaml
repos:
- repo: https://github.com/ppiankov/pastewatch
rev: v0.33.2
rev: v0.33.3
hooks:
- id: pastewatch
```
Expand Down
2 changes: 1 addition & 1 deletion docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Current State

**Stable, feature-complete - v0.33.2**
**Stable, feature-complete - v0.33.3**

Accepting compatibility, safety, and bug fixes only. No major new features planned.

Expand Down
Loading