@miphamai/cli 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/workflows/audit.js +66 -18
- package/src/agent/agent-context.ts +20 -57
- package/src/agent/agent-experience.ts +8 -0
- package/src/agent/cross-session/discovery.ts +99 -0
- package/src/agent/cross-session/file-inbox.ts +118 -0
- package/src/agent/cross-session/transport.ts +17 -0
- package/src/agent/effectiveness-tracker.ts +149 -0
- package/src/agent/experience-rules.ts +188 -0
- package/src/agent/message-router.ts +83 -0
- package/src/agent/pattern-analyzer.ts +154 -0
- package/src/agent/sub-agent.ts +67 -0
- package/src/agent/types.ts +2 -0
- package/src/config/defaults.ts +12 -0
- package/src/config/loader.ts +33 -0
- package/src/core/credential-masker/env-filter.ts +39 -0
- package/src/core/credential-masker/index.ts +35 -0
- package/src/core/credential-masker/matcher.ts +106 -0
- package/src/core/credential-masker/output-scrub.ts +27 -0
- package/src/core/credential-masker/pipeline.ts +67 -0
- package/src/core/credential-masker/strategies/aws.ts +50 -0
- package/src/core/credential-masker/strategies/extract.ts +106 -0
- package/src/core/credential-masker/strategies/full.ts +15 -0
- package/src/core/credential-masker/strategies/jwt.ts +75 -0
- package/src/core/credential-masker/types.ts +16 -0
- package/src/core/credential-masker.ts +12 -187
- package/src/core/engine.ts +114 -10
- package/src/core/rule-engine.ts +179 -0
- package/src/core/session-store.ts +15 -2
- package/src/index.tsx +47 -2
- package/src/shared/types.ts +69 -5
- package/src/tools/agent/agent.ts +7 -1
- package/src/tools/agent/list-agents.ts +67 -0
- package/src/tools/agent/send-message.ts +21 -24
- package/src/tools/exec/bash.ts +67 -3
- package/src/tools/index.ts +2 -0
- package/src/ui/commands.ts +133 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import type { CredentialMaskingConfig, CredentialFileRule } from '../../shared/index.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if a file path matches any credential file rule.
|
|
6
|
+
* Returns the matching rule, or null if no match.
|
|
7
|
+
*/
|
|
8
|
+
export function matchCredentialFile(
|
|
9
|
+
filePath: string,
|
|
10
|
+
config: CredentialMaskingConfig,
|
|
11
|
+
): CredentialFileRule | null {
|
|
12
|
+
if (!config.enabled || config.files.length === 0) return null
|
|
13
|
+
|
|
14
|
+
const expanded = expandHome(filePath)
|
|
15
|
+
for (const rule of config.files) {
|
|
16
|
+
if (matchPath(expanded, rule.path)) {
|
|
17
|
+
return rule
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Internal helpers ──
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Expand ~ to the user's home directory.
|
|
27
|
+
*/
|
|
28
|
+
export function expandHome(p: string): string {
|
|
29
|
+
if (p.startsWith('~')) {
|
|
30
|
+
return homedir() + p.slice(1)
|
|
31
|
+
}
|
|
32
|
+
return p
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Match a file path against a glob-like pattern.
|
|
37
|
+
* Supports:
|
|
38
|
+
* - ** globstar (matches any number of path segments)
|
|
39
|
+
* - * wildcard within a single path segment
|
|
40
|
+
* - ? single char
|
|
41
|
+
* - ~ home directory expansion
|
|
42
|
+
* - basename-only matching (e.g. ".env*" matches any .env file)
|
|
43
|
+
*/
|
|
44
|
+
export function matchPath(filePath: string, pattern: string): boolean {
|
|
45
|
+
const expandedPattern = expandHome(pattern)
|
|
46
|
+
const normalizedFile = filePath.replace(/\\/g, '/')
|
|
47
|
+
const normalizedPattern = expandedPattern.replace(/\\/g, '/')
|
|
48
|
+
|
|
49
|
+
if (normalizedFile === normalizedPattern) return true
|
|
50
|
+
|
|
51
|
+
// File ends with pattern (for relative basename matching like ".env*")
|
|
52
|
+
if (normalizedFile.endsWith('/' + normalizedPattern)) return true
|
|
53
|
+
|
|
54
|
+
const regex = globToRegex(normalizedPattern)
|
|
55
|
+
if (regex.test(normalizedFile)) return true
|
|
56
|
+
|
|
57
|
+
// Basename-only matching: patterns without path separators match
|
|
58
|
+
// against the last component of the file path (e.g. ".env*" matches any .env file)
|
|
59
|
+
if (!normalizedPattern.includes('/')) {
|
|
60
|
+
const basename = normalizedFile.split('/').pop() || normalizedFile
|
|
61
|
+
if (regex.test(basename)) return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Convert a glob pattern to a RegExp.
|
|
69
|
+
* - ** → .* (matches any path segments)
|
|
70
|
+
* - * → [^/]* (matches within a single segment)
|
|
71
|
+
* - ? → . (single char)
|
|
72
|
+
* - All other special regex chars are escaped.
|
|
73
|
+
*/
|
|
74
|
+
export function globToRegex(pattern: string): RegExp {
|
|
75
|
+
let regexStr = ''
|
|
76
|
+
let i = 0
|
|
77
|
+
while (i < pattern.length) {
|
|
78
|
+
const ch = pattern[i]!
|
|
79
|
+
if (ch === '*' && pattern[i + 1] === '*') {
|
|
80
|
+
// ** — match zero or more path segments
|
|
81
|
+
regexStr += '.*'
|
|
82
|
+
i += 2
|
|
83
|
+
// Skip trailing slash after **
|
|
84
|
+
if (pattern[i] === '/') i++
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (ch === '*') {
|
|
88
|
+
regexStr += '[^/]*'
|
|
89
|
+
i++
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (ch === '?') {
|
|
93
|
+
regexStr += '.'
|
|
94
|
+
i++
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
// Escape regex special chars
|
|
98
|
+
if ('\\^$.|+()[]{}'.includes(ch)) {
|
|
99
|
+
regexStr += '\\' + ch
|
|
100
|
+
} else {
|
|
101
|
+
regexStr += ch
|
|
102
|
+
}
|
|
103
|
+
i++
|
|
104
|
+
}
|
|
105
|
+
return new RegExp('^' + regexStr + '$')
|
|
106
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CREDENTIAL_SENTINEL } from './types'
|
|
2
|
+
import type { CredentialMaskingConfig } from '../../shared/index.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Scrub credential patterns from stdout/stderr output.
|
|
6
|
+
* Uses the configured output_scrubbing patterns to detect and replace
|
|
7
|
+
* credentials that may have leaked into command output.
|
|
8
|
+
*/
|
|
9
|
+
export function maskOutput(output: string, config: CredentialMaskingConfig): string {
|
|
10
|
+
if (!config.enabled || !config.output_scrubbing.enabled) return output
|
|
11
|
+
|
|
12
|
+
let masked = output
|
|
13
|
+
for (const pattern of config.output_scrubbing.patterns) {
|
|
14
|
+
try {
|
|
15
|
+
// Strip (?i) inline flags — JS uses the 'i' flag instead
|
|
16
|
+
const clean = pattern.replace(/^\(\?i\)/, '')
|
|
17
|
+
const regex = new RegExp(clean, 'gim')
|
|
18
|
+
masked = masked.replace(regex, (match) => {
|
|
19
|
+
// Replace everything after the separator (= or :)
|
|
20
|
+
return match.replace(/\s*[:=]\s*\S+/, `=${CREDENTIAL_SENTINEL}`)
|
|
21
|
+
})
|
|
22
|
+
} catch {
|
|
23
|
+
// Invalid regex — skip
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return masked
|
|
27
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CredentialFileRule } from '../../shared/index.ts'
|
|
2
|
+
import type { MaskingStrategy } from './types'
|
|
3
|
+
import { CREDENTIAL_SENTINEL } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MaskingPipeline chains multiple MaskingStrategy instances.
|
|
7
|
+
* When maskContent is called, the first strategy whose canHandle()
|
|
8
|
+
* returns true performs the masking. If no strategy matches, falls
|
|
9
|
+
* back to legacy full/extract behavior.
|
|
10
|
+
*/
|
|
11
|
+
export class MaskingPipeline {
|
|
12
|
+
private strategies: MaskingStrategy[] = []
|
|
13
|
+
|
|
14
|
+
/** Register a strategy. Later registrations take priority (checked first). */
|
|
15
|
+
register(strategy: MaskingStrategy): void {
|
|
16
|
+
this.strategies.unshift(strategy)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Apply masking to content according to a rule. */
|
|
20
|
+
maskContent(content: string, rule: CredentialFileRule): string {
|
|
21
|
+
for (const strategy of this.strategies) {
|
|
22
|
+
if (strategy.canHandle(rule)) {
|
|
23
|
+
return strategy.mask(content, rule)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fallback to legacy full/extract behavior
|
|
28
|
+
if ('mode' in rule && rule.mode === 'full') {
|
|
29
|
+
return CREDENTIAL_SENTINEL
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Legacy extract mode
|
|
33
|
+
if ('mode' in rule && 'extract' in rule && Array.isArray(rule.extract)) {
|
|
34
|
+
let masked = content
|
|
35
|
+
for (const extractRule of rule.extract) {
|
|
36
|
+
const replacement = extractRule.replacement || CREDENTIAL_SENTINEL
|
|
37
|
+
try {
|
|
38
|
+
const regex = new RegExp(extractRule.pattern, 'gm')
|
|
39
|
+
masked = masked.replace(regex, replacement)
|
|
40
|
+
} catch {
|
|
41
|
+
// Invalid regex — skip this rule silently
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return masked
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return content
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Singleton pipeline instance. */
|
|
52
|
+
let _pipeline: MaskingPipeline | null = null
|
|
53
|
+
|
|
54
|
+
export function getPipeline(): MaskingPipeline {
|
|
55
|
+
if (!_pipeline) {
|
|
56
|
+
_pipeline = new MaskingPipeline()
|
|
57
|
+
}
|
|
58
|
+
return _pipeline
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Convenience function matching the original maskContent signature.
|
|
63
|
+
* Uses the singleton pipeline.
|
|
64
|
+
*/
|
|
65
|
+
export function maskContent(content: string, rule: CredentialFileRule): string {
|
|
66
|
+
return getPipeline().maskContent(content, rule)
|
|
67
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { MaskingStrategy } from '../types'
|
|
2
|
+
import type { CredentialFileRule, AwsMaskingRule } from '../../../shared/index.ts'
|
|
3
|
+
import { CREDENTIAL_SENTINEL } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* AWS credential masking strategy.
|
|
7
|
+
*
|
|
8
|
+
* Phase 1: Detects AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY pairs
|
|
9
|
+
* and masks the secret key while preserving partial access key for context.
|
|
10
|
+
* SigV4 re-signing is deferred to Phase 2 (requires TLS termination).
|
|
11
|
+
*/
|
|
12
|
+
export class AwsMaskingStrategy implements MaskingStrategy {
|
|
13
|
+
readonly name = 'aws'
|
|
14
|
+
|
|
15
|
+
canHandle(rule: CredentialFileRule): rule is AwsMaskingRule {
|
|
16
|
+
return 'type' in rule && rule.type === 'aws'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
mask(content: string, rule: AwsMaskingRule): string {
|
|
20
|
+
if (!rule.awsPairs) return content
|
|
21
|
+
|
|
22
|
+
let masked = content
|
|
23
|
+
|
|
24
|
+
// Mask access key values — keep prefix for model context
|
|
25
|
+
masked = masked.replace(/((?:AKIA|ASIA)[A-Z0-9]{12,})/g, (_full, key: string) => {
|
|
26
|
+
const prefix = key.slice(0, 8) // "AKIAXXXX"
|
|
27
|
+
return `${prefix}${CREDENTIAL_SENTINEL}`
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Mask secret access key values — fully mask (no context needed)
|
|
31
|
+
masked = masked.replace(
|
|
32
|
+
/(?:AWS_SECRET_ACCESS_KEY|aws_secret_access_key)\s*[:=]\s*([A-Za-z0-9/+=]{40,})/gi,
|
|
33
|
+
(match) => {
|
|
34
|
+
const parts = match.split(/[:=]\s*/)
|
|
35
|
+
return `${parts[0]}=${CREDENTIAL_SENTINEL}`
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
// Also mask inline secret key patterns (bare base64-like strings near key markers)
|
|
40
|
+
masked = masked.replace(
|
|
41
|
+
/(?:aws_secret_access_key|secret_access_key|SECRET_ACCESS_KEY)\s*[:=]\s*([A-Za-z0-9/+=]{40,})/gi,
|
|
42
|
+
(match) => {
|
|
43
|
+
const parts = match.split(/[:=]\s*/)
|
|
44
|
+
return `${parts[0]}=${CREDENTIAL_SENTINEL}`
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return masked
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { MaskingStrategy } from '../types'
|
|
2
|
+
import type { CredentialFileRule, CredentialExtractRule } from '../../../shared/index.ts'
|
|
3
|
+
import { CREDENTIAL_SENTINEL } from '../types'
|
|
4
|
+
|
|
5
|
+
export class ExtractMaskingStrategy implements MaskingStrategy {
|
|
6
|
+
readonly name = 'extract'
|
|
7
|
+
|
|
8
|
+
canHandle(rule: CredentialFileRule): rule is CredentialExtractRule {
|
|
9
|
+
return 'mode' in rule && rule.mode === 'extract' && !('type' in rule)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
mask(content: string, rule: CredentialExtractRule): string {
|
|
13
|
+
let masked = content
|
|
14
|
+
let anyMatch = false
|
|
15
|
+
|
|
16
|
+
for (const extractRule of rule.extract) {
|
|
17
|
+
const replacement = extractRule.replacement || CREDENTIAL_SENTINEL
|
|
18
|
+
|
|
19
|
+
if (extractRule.field) {
|
|
20
|
+
// Structured extraction: extract field by key before applying pattern
|
|
21
|
+
masked = this.maskField(masked, extractRule.field, extractRule.pattern, replacement)
|
|
22
|
+
anyMatch = true // field extraction always applies
|
|
23
|
+
} else {
|
|
24
|
+
// Traditional regex extraction
|
|
25
|
+
try {
|
|
26
|
+
const regex = new RegExp(extractRule.pattern, 'gm')
|
|
27
|
+
if (regex.test(masked)) {
|
|
28
|
+
anyMatch = true
|
|
29
|
+
// Re-create regex since test() advances lastIndex
|
|
30
|
+
const re = new RegExp(extractRule.pattern, 'gm')
|
|
31
|
+
masked = masked.replace(re, replacement)
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Invalid regex — skip
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Handle onExtractNoMatch
|
|
40
|
+
const onNoMatch = rule.onExtractNoMatch || 'mask'
|
|
41
|
+
if (!anyMatch && onNoMatch === 'mask') {
|
|
42
|
+
return CREDENTIAL_SENTINEL
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return masked
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract a JSON field by key and apply pattern masking to its value.
|
|
50
|
+
* Supports nested keys with dot notation (e.g. "auth.api_key").
|
|
51
|
+
*/
|
|
52
|
+
private maskField(
|
|
53
|
+
content: string,
|
|
54
|
+
fieldPath: string,
|
|
55
|
+
pattern: string,
|
|
56
|
+
replacement: string,
|
|
57
|
+
): string {
|
|
58
|
+
try {
|
|
59
|
+
// Try parsing as JSON
|
|
60
|
+
const parsed = JSON.parse(content)
|
|
61
|
+
this.setNestedValue(parsed, fieldPath, (value) => {
|
|
62
|
+
if (typeof value === 'string') {
|
|
63
|
+
try {
|
|
64
|
+
const regex = new RegExp(pattern)
|
|
65
|
+
return value.replace(regex, replacement)
|
|
66
|
+
} catch {
|
|
67
|
+
return replacement
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return replacement
|
|
71
|
+
})
|
|
72
|
+
return JSON.stringify(parsed, null, 2)
|
|
73
|
+
} catch {
|
|
74
|
+
// Not valid JSON — fall back to regex on raw content
|
|
75
|
+
try {
|
|
76
|
+
const regex = new RegExp(pattern, 'gm')
|
|
77
|
+
return content.replace(regex, replacement)
|
|
78
|
+
} catch {
|
|
79
|
+
return content
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Set a nested value in an object using dot-notation path.
|
|
86
|
+
*/
|
|
87
|
+
private setNestedValue(
|
|
88
|
+
obj: Record<string, unknown>,
|
|
89
|
+
path: string,
|
|
90
|
+
transform: (value: unknown) => unknown,
|
|
91
|
+
): void {
|
|
92
|
+
const keys = path.split('.')
|
|
93
|
+
let current: Record<string, unknown> = obj
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
96
|
+
const key = keys[i]!
|
|
97
|
+
if (!current[key] || typeof current[key] !== 'object') return
|
|
98
|
+
current = current[key] as Record<string, unknown>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const lastKey = keys[keys.length - 1]!
|
|
102
|
+
if (lastKey in current) {
|
|
103
|
+
current[lastKey] = transform(current[lastKey])
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MaskingStrategy } from '../types'
|
|
2
|
+
import type { CredentialFileRule, CredentialFullMaskRule } from '../../../shared/index.ts'
|
|
3
|
+
import { CREDENTIAL_SENTINEL } from '../types'
|
|
4
|
+
|
|
5
|
+
export class FullMaskingStrategy implements MaskingStrategy {
|
|
6
|
+
readonly name = 'full'
|
|
7
|
+
|
|
8
|
+
canHandle(rule: CredentialFileRule): rule is CredentialFullMaskRule {
|
|
9
|
+
return 'mode' in rule && rule.mode === 'full' && !('type' in rule)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
mask(_content: string, _rule: CredentialFullMaskRule): string {
|
|
13
|
+
return CREDENTIAL_SENTINEL
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { MaskingStrategy } from '../types'
|
|
2
|
+
import type { CredentialFileRule, JwtMaskingRule } from '../../../shared/index.ts'
|
|
3
|
+
|
|
4
|
+
export class JwtMaskingStrategy implements MaskingStrategy {
|
|
5
|
+
readonly name = 'jwt'
|
|
6
|
+
|
|
7
|
+
canHandle(rule: CredentialFileRule): rule is JwtMaskingRule {
|
|
8
|
+
return 'type' in rule && rule.type === 'jwt'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
mask(content: string, rule: JwtMaskingRule): string {
|
|
12
|
+
return content
|
|
13
|
+
.split('\n')
|
|
14
|
+
.map((line) => this.maskLine(line, rule))
|
|
15
|
+
.join('\n')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private maskLine(line: string, rule: JwtMaskingRule): string {
|
|
19
|
+
const trimmed = line.trim()
|
|
20
|
+
|
|
21
|
+
// Check if this looks like a JWT (three base64url segments separated by dots)
|
|
22
|
+
const segments = trimmed.split('.')
|
|
23
|
+
if (segments.length !== 3) return line
|
|
24
|
+
|
|
25
|
+
// Verify each segment is base64url
|
|
26
|
+
const b64urlRegex = /^[A-Za-z0-9_-]+$/
|
|
27
|
+
if (!segments.every((s) => b64urlRegex.test(s))) return line
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const header = segments[0]!
|
|
31
|
+
const payload = segments[1]!
|
|
32
|
+
const signature = segments[2]!
|
|
33
|
+
|
|
34
|
+
// Decode payload
|
|
35
|
+
const decodedPayload = this.decodeBase64Url(payload)
|
|
36
|
+
let payloadObj: Record<string, unknown>
|
|
37
|
+
try {
|
|
38
|
+
payloadObj = JSON.parse(decodedPayload)
|
|
39
|
+
} catch {
|
|
40
|
+
return line // Not valid JSON — not a JWT
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Mask specified claims
|
|
44
|
+
let modified = false
|
|
45
|
+
for (const claim of rule.maskClaims) {
|
|
46
|
+
if (claim in payloadObj) {
|
|
47
|
+
payloadObj[claim] = '[MASKED]'
|
|
48
|
+
modified = true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!modified) return line
|
|
53
|
+
|
|
54
|
+
// Re-encode payload
|
|
55
|
+
const newPayload = this.encodeBase64Url(JSON.stringify(payloadObj))
|
|
56
|
+
return `${header}.${newPayload}.${signature}`
|
|
57
|
+
} catch {
|
|
58
|
+
return line // Decode failure — return unchanged
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private decodeBase64Url(str: string): string {
|
|
63
|
+
// Convert base64url to base64
|
|
64
|
+
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
|
|
65
|
+
// Pad
|
|
66
|
+
const padded = base64 + '==='.slice(0, (4 - (base64.length % 4)) % 4)
|
|
67
|
+
// Decode
|
|
68
|
+
return Buffer.from(padded, 'base64').toString('utf-8')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private encodeBase64Url(str: string): string {
|
|
72
|
+
const base64 = Buffer.from(str, 'utf-8').toString('base64')
|
|
73
|
+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CredentialFileRule } from '../../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
/** Sentinel value used to replace masked credentials. */
|
|
4
|
+
export const CREDENTIAL_SENTINEL = '__MIPHAM_CREDENTIAL_MASKED__'
|
|
5
|
+
|
|
6
|
+
/** Strategy interface for credential masking. Each strategy handles one rule type. */
|
|
7
|
+
export interface MaskingStrategy {
|
|
8
|
+
/** Unique name for this strategy. */
|
|
9
|
+
readonly name: string
|
|
10
|
+
|
|
11
|
+
/** Return true if this strategy handles the given rule. */
|
|
12
|
+
canHandle(rule: CredentialFileRule): boolean
|
|
13
|
+
|
|
14
|
+
/** Apply masking to content according to the rule. Returns masked content. */
|
|
15
|
+
mask(content: string, rule: CredentialFileRule): string
|
|
16
|
+
}
|
|
@@ -1,117 +1,23 @@
|
|
|
1
|
-
import { homedir } from 'node:os'
|
|
2
|
-
import type { CredentialMaskingConfig, CredentialFileRule } from '../shared/index.ts'
|
|
3
|
-
|
|
4
|
-
/** Sentinel value used to replace masked credentials. */
|
|
5
|
-
export const CREDENTIAL_SENTINEL = '__MIPHAM_CREDENTIAL_MASKED__'
|
|
6
|
-
|
|
7
1
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* Backward-compatible re-exports from the modular credential-masker/ pipeline.
|
|
3
|
+
*
|
|
4
|
+
* This file preserves the original API surface while delegating to the
|
|
5
|
+
* modular strategy-based implementation under credential-masker/.
|
|
10
6
|
*/
|
|
11
|
-
export function matchCredentialFile(
|
|
12
|
-
filePath: string,
|
|
13
|
-
config: CredentialMaskingConfig,
|
|
14
|
-
): CredentialFileRule | null {
|
|
15
|
-
if (!config.enabled || config.files.length === 0) return null
|
|
16
|
-
|
|
17
|
-
const expanded = expandHome(filePath)
|
|
18
|
-
for (const rule of config.files) {
|
|
19
|
-
if (matchPath(expanded, rule.path)) {
|
|
20
|
-
return rule
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return null
|
|
24
|
-
}
|
|
25
7
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
* - 'full' mode: return sentinel value only
|
|
29
|
-
* - 'extract' mode: replace regex-matched tokens with sentinel
|
|
30
|
-
*/
|
|
31
|
-
export function maskContent(content: string, rule: CredentialFileRule): string {
|
|
32
|
-
if (rule.mode === 'full') {
|
|
33
|
-
return CREDENTIAL_SENTINEL
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// extract mode
|
|
37
|
-
let masked = content
|
|
38
|
-
for (const extractRule of rule.extract) {
|
|
39
|
-
const replacement = extractRule.replacement || CREDENTIAL_SENTINEL
|
|
40
|
-
try {
|
|
41
|
-
const regex = new RegExp(extractRule.pattern, 'gm')
|
|
42
|
-
masked = masked.replace(regex, replacement)
|
|
43
|
-
} catch {
|
|
44
|
-
// Invalid regex — skip this rule silently
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return masked
|
|
48
|
-
}
|
|
8
|
+
// Re-export sentinel constant
|
|
9
|
+
export { CREDENTIAL_SENTINEL } from './credential-masker/types'
|
|
49
10
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
export function maskOutput(output: string, config: CredentialMaskingConfig): string {
|
|
56
|
-
if (!config.enabled || !config.output_scrubbing.enabled) return output
|
|
57
|
-
|
|
58
|
-
let masked = output
|
|
59
|
-
for (const pattern of config.output_scrubbing.patterns) {
|
|
60
|
-
try {
|
|
61
|
-
// Strip (?i) inline flags — JS uses the 'i' flag instead
|
|
62
|
-
const clean = pattern.replace(/^\(\?i\)/, '')
|
|
63
|
-
const regex = new RegExp(clean, 'gim')
|
|
64
|
-
masked = masked.replace(regex, (match) => {
|
|
65
|
-
// Replace everything after the separator (= or :)
|
|
66
|
-
return match.replace(/\s*[:=]\s*\S+/, `=${CREDENTIAL_SENTINEL}`)
|
|
67
|
-
})
|
|
68
|
-
} catch {
|
|
69
|
-
// Invalid regex — skip
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return masked
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Filter sensitive environment variables before spawning a subprocess.
|
|
77
|
-
* Returns a sanitized copy of process.env with matching vars removed.
|
|
78
|
-
*/
|
|
79
|
-
export function filterEnv(
|
|
80
|
-
env: Record<string, string | undefined>,
|
|
81
|
-
config: CredentialMaskingConfig,
|
|
82
|
-
): Record<string, string | undefined> {
|
|
83
|
-
if (!config.enabled || !config.env_filter.enabled) return { ...env }
|
|
84
|
-
|
|
85
|
-
const filtered: Record<string, string | undefined> = {}
|
|
86
|
-
for (const [key, value] of Object.entries(env)) {
|
|
87
|
-
let blocked = false
|
|
88
|
-
for (const pattern of config.env_filter.patterns) {
|
|
89
|
-
try {
|
|
90
|
-
// Strip (?i) inline flags — JS uses the 'i' flag instead
|
|
91
|
-
const clean = pattern.replace(/^\(\?i\)/, '')
|
|
92
|
-
const regex = new RegExp(clean, 'i')
|
|
93
|
-
if (regex.test(key)) {
|
|
94
|
-
blocked = true
|
|
95
|
-
break
|
|
96
|
-
}
|
|
97
|
-
} catch {
|
|
98
|
-
// Invalid regex — skip
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
if (blocked) {
|
|
102
|
-
// Replace with sentinel so tools can detect they're filtered
|
|
103
|
-
filtered[key] = CREDENTIAL_SENTINEL
|
|
104
|
-
} else {
|
|
105
|
-
filtered[key] = value
|
|
106
|
-
}
|
|
107
|
-
}
|
|
11
|
+
// Re-export core functions
|
|
12
|
+
export { matchCredentialFile } from './credential-masker/matcher'
|
|
13
|
+
export { maskContent } from './credential-masker/pipeline'
|
|
14
|
+
export { maskOutput } from './credential-masker/output-scrub'
|
|
15
|
+
export { filterEnv } from './credential-masker/env-filter'
|
|
108
16
|
|
|
109
|
-
|
|
110
|
-
}
|
|
17
|
+
import { CREDENTIAL_SENTINEL } from './credential-masker/types'
|
|
111
18
|
|
|
112
19
|
/**
|
|
113
20
|
* Get the file display name for a sentinel value (for model visibility).
|
|
114
|
-
* The model sees the sentinel, doesn't know the original file path.
|
|
115
21
|
*/
|
|
116
22
|
export function getSentinelDisplay(hint?: string): string {
|
|
117
23
|
if (hint) {
|
|
@@ -119,84 +25,3 @@ export function getSentinelDisplay(hint?: string): string {
|
|
|
119
25
|
}
|
|
120
26
|
return CREDENTIAL_SENTINEL
|
|
121
27
|
}
|
|
122
|
-
|
|
123
|
-
// ── Internal helpers ──
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Expand ~ to the user's home directory.
|
|
127
|
-
*/
|
|
128
|
-
function expandHome(p: string): string {
|
|
129
|
-
if (p.startsWith('~')) {
|
|
130
|
-
return homedir() + p.slice(1)
|
|
131
|
-
}
|
|
132
|
-
return p
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Match a file path against a glob-like pattern.
|
|
137
|
-
* Supports:
|
|
138
|
-
* - ** globstar (matches any number of path segments)
|
|
139
|
-
* - * wildcard within a single path segment
|
|
140
|
-
* - ~ home directory expansion
|
|
141
|
-
* - basename-only matching (e.g. ".env*" matches any .env file)
|
|
142
|
-
*/
|
|
143
|
-
function matchPath(filePath: string, pattern: string): boolean {
|
|
144
|
-
const expandedPattern = expandHome(pattern)
|
|
145
|
-
const normalizedFile = filePath.replace(/\\/g, '/')
|
|
146
|
-
const normalizedPattern = expandedPattern.replace(/\\/g, '/')
|
|
147
|
-
|
|
148
|
-
// Exact match
|
|
149
|
-
if (normalizedFile === normalizedPattern) {
|
|
150
|
-
return true
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// File ends with pattern (for relative basename matching like ".env*")
|
|
154
|
-
if (normalizedFile.endsWith('/' + normalizedPattern)) {
|
|
155
|
-
return true
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Convert glob pattern to regex
|
|
159
|
-
const regex = globToRegex(normalizedPattern)
|
|
160
|
-
return regex.test(normalizedFile)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Convert a glob pattern to a RegExp.
|
|
165
|
-
* - ** → .* (matches any path segments)
|
|
166
|
-
* - * → [^/]* (matches within a single segment)
|
|
167
|
-
* - ? → . (single char)
|
|
168
|
-
* - All other special regex chars are escaped.
|
|
169
|
-
*/
|
|
170
|
-
function globToRegex(pattern: string): RegExp {
|
|
171
|
-
let regexStr = ''
|
|
172
|
-
let i = 0
|
|
173
|
-
while (i < pattern.length) {
|
|
174
|
-
const ch = pattern[i]!
|
|
175
|
-
if (ch === '*' && pattern[i + 1] === '*') {
|
|
176
|
-
// ** — match zero or more path segments
|
|
177
|
-
regexStr += '.*'
|
|
178
|
-
i += 2
|
|
179
|
-
// Skip trailing slash after **
|
|
180
|
-
if (pattern[i] === '/') i++
|
|
181
|
-
continue
|
|
182
|
-
}
|
|
183
|
-
if (ch === '*') {
|
|
184
|
-
regexStr += '[^/]*'
|
|
185
|
-
i++
|
|
186
|
-
continue
|
|
187
|
-
}
|
|
188
|
-
if (ch === '?') {
|
|
189
|
-
regexStr += '.'
|
|
190
|
-
i++
|
|
191
|
-
continue
|
|
192
|
-
}
|
|
193
|
-
// Escape regex special chars
|
|
194
|
-
if ('\\^$.|+()[]{}'.includes(ch)) {
|
|
195
|
-
regexStr += '\\' + ch
|
|
196
|
-
} else {
|
|
197
|
-
regexStr += ch
|
|
198
|
-
}
|
|
199
|
-
i++
|
|
200
|
-
}
|
|
201
|
-
return new RegExp('^' + regexStr + '$')
|
|
202
|
-
}
|