cli-mem 0.3.4__tar.gz → 0.3.5__tar.gz
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.
- {cli_mem-0.3.4 → cli_mem-0.3.5}/PKG-INFO +1 -1
- {cli_mem-0.3.4 → cli_mem-0.3.5}/pyproject.toml +1 -1
- {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/__init__.py +1 -1
- {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/variables.py +66 -13
- {cli_mem-0.3.4 → cli_mem-0.3.5}/tests/test_groups.py +107 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/.github/workflows/ci.yml +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/.github/workflows/release.yml +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/.gitignore +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/ARCHITECTURE.md +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/LICENSE +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/PHILOSOPHY.md +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/README.md +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/assets/.gitkeep +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/docs/decisions/001-jsonl-over-sqlite.md +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/docs/decisions/002-apple-fm-sdk-for-patterns.md +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/docs/decisions/003-no-daemon.md +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/docs/decisions/004-per-repo-jsonl.md +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/hooks/mem.zsh +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/install.sh +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/_generable.py +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/capture.py +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/cli.py +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/groups.py +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/models.py +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/patterns.py +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/search.py +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/storage.py +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/tests/conftest.py +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/tests/test_capture.py +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/tests/test_patterns.py +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/tests/test_search.py +0 -0
- {cli_mem-0.3.4 → cli_mem-0.3.5}/tests/test_storage.py +0 -0
|
@@ -313,6 +313,41 @@ def _normalize_var_name(name: str) -> str:
|
|
|
313
313
|
return result.strip("_")
|
|
314
314
|
|
|
315
315
|
|
|
316
|
+
def _extract_value_from_syntax(original_value: str, cmd: str) -> str:
|
|
317
|
+
"""Extract the actual secret value from flag=value or KEY=value syntax.
|
|
318
|
+
|
|
319
|
+
The AI sometimes returns 'GITHUB_TOKEN=ghp_abc...' or '--password=secret'
|
|
320
|
+
as the original_value. We need just the secret part so that .replace()
|
|
321
|
+
doesn't destroy the command structure.
|
|
322
|
+
"""
|
|
323
|
+
# Pattern: --flag=value or -f=value (CLI flag syntax)
|
|
324
|
+
flag_match = re.match(r"^--?[a-zA-Z][\w-]*=(.+)$", original_value)
|
|
325
|
+
if flag_match:
|
|
326
|
+
value = flag_match.group(1)
|
|
327
|
+
if value in cmd:
|
|
328
|
+
return value
|
|
329
|
+
|
|
330
|
+
# Pattern: ENV_VAR=value (environment variable assignment)
|
|
331
|
+
env_match = re.match(r"^[A-Z][A-Z0-9_]+=(.+)$", original_value)
|
|
332
|
+
if env_match:
|
|
333
|
+
value = env_match.group(1)
|
|
334
|
+
if value in cmd:
|
|
335
|
+
return value
|
|
336
|
+
|
|
337
|
+
return original_value
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _looks_like_hostname(value: str) -> bool:
|
|
341
|
+
"""Check if a value looks like a hostname or domain, not a credential."""
|
|
342
|
+
# Hostnames: words joined by dots, no special chars typical of secrets
|
|
343
|
+
if re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9-]*\.)+[a-zA-Z]{2,}$", value):
|
|
344
|
+
return True
|
|
345
|
+
# IP addresses
|
|
346
|
+
if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", value):
|
|
347
|
+
return True
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
|
|
316
351
|
def _deduplicate_detections(
|
|
317
352
|
detections: list[tuple[str, str, str]],
|
|
318
353
|
cmd: str,
|
|
@@ -325,30 +360,39 @@ def _deduplicate_detections(
|
|
|
325
360
|
- Very short values (< 8 chars, not plausible secrets)
|
|
326
361
|
- Duplicate original_value entries
|
|
327
362
|
- Values that are substrings of other detected values
|
|
363
|
+
|
|
364
|
+
Also extracts the actual secret from flag=value syntax when the AI
|
|
365
|
+
returns the whole expression as original_value.
|
|
328
366
|
"""
|
|
329
367
|
result: list[tuple[str, str, str]] = []
|
|
330
368
|
seen_values: set[str] = set()
|
|
331
369
|
|
|
332
370
|
for original_value, suggested_name, reason in detections:
|
|
371
|
+
# Extract actual value from flag=value or ENV=value syntax
|
|
372
|
+
value = _extract_value_from_syntax(original_value, cmd)
|
|
373
|
+
|
|
333
374
|
# Skip hallucinated values not in the command
|
|
334
|
-
if
|
|
375
|
+
if value not in cmd:
|
|
335
376
|
continue
|
|
336
377
|
# Skip URLs
|
|
337
|
-
if
|
|
378
|
+
if value.startswith(("http://", "https://")):
|
|
379
|
+
continue
|
|
380
|
+
# Skip hostnames and IP addresses
|
|
381
|
+
if _looks_like_hostname(value):
|
|
338
382
|
continue
|
|
339
383
|
# Skip very short values
|
|
340
|
-
if len(
|
|
384
|
+
if len(value) < 8:
|
|
341
385
|
continue
|
|
342
386
|
# Skip duplicates
|
|
343
|
-
if
|
|
387
|
+
if value in seen_values:
|
|
344
388
|
continue
|
|
345
389
|
# Normalize suggested name to UPPER_SNAKE_CASE
|
|
346
390
|
normalized = _normalize_var_name(suggested_name)
|
|
347
391
|
if not re.match(r"^[A-Z][A-Z0-9_]+$", normalized):
|
|
348
392
|
continue
|
|
349
393
|
|
|
350
|
-
seen_values.add(
|
|
351
|
-
result.append((
|
|
394
|
+
seen_values.add(value)
|
|
395
|
+
result.append((value, normalized, reason))
|
|
352
396
|
|
|
353
397
|
# Remove values that are substrings of other detected values
|
|
354
398
|
filtered: list[tuple[str, str, str]] = []
|
|
@@ -387,17 +431,26 @@ async def _detect_credentials_async(cmd: str) -> list[tuple[str, str, str]]:
|
|
|
387
431
|
prompt = (
|
|
388
432
|
"Analyze this shell command and find ONLY hardcoded sensitive values "
|
|
389
433
|
"that should be replaced with environment variables.\n\n"
|
|
434
|
+
"RULES FOR original_value:\n"
|
|
435
|
+
"- Return ONLY the literal secret value, not the flag or key name.\n"
|
|
436
|
+
"- For --password=mysecret, return 'mysecret' not '--password=mysecret'.\n"
|
|
437
|
+
"- For GITHUB_TOKEN=ghp_abc, return 'ghp_abc' not 'GITHUB_TOKEN=ghp_abc'.\n"
|
|
438
|
+
"- For postgres://user:pass@host, return 'pass' not the full URL.\n\n"
|
|
439
|
+
"RULES FOR suggested_name:\n"
|
|
440
|
+
"- Derive the name from the service or tool in the command.\n"
|
|
441
|
+
"- Examples: STRIPE_API_KEY, GITHUB_TOKEN, REDIS_PASSWORD, DB_PASSWORD.\n"
|
|
442
|
+
"- NEVER use generic prefixes like ACME_.\n\n"
|
|
390
443
|
"ONLY flag these types of values:\n"
|
|
391
|
-
"- API tokens or keys (long alphanumeric/base64 strings
|
|
392
|
-
"- Passwords passed inline (after --password, -p, etc.)\n"
|
|
444
|
+
"- API tokens or keys (long alphanumeric/base64 strings)\n"
|
|
445
|
+
"- Passwords passed inline (after --password, -p, -a, etc.)\n"
|
|
393
446
|
"- Bearer tokens in Authorization headers\n"
|
|
394
|
-
"-
|
|
447
|
+
"- Embedded passwords in connection strings\n\n"
|
|
395
448
|
"DO NOT flag:\n"
|
|
396
|
-
"- URLs, hostnames, or
|
|
397
|
-
"- The command name or
|
|
449
|
+
"- URLs, hostnames, IP addresses, or domain names\n"
|
|
450
|
+
"- The command name, subcommands, or usernames\n"
|
|
398
451
|
"- Short strings, regular arguments, or file paths\n"
|
|
399
|
-
"- Port numbers or
|
|
400
|
-
"If
|
|
452
|
+
"- Port numbers, known constants, or registry addresses\n\n"
|
|
453
|
+
"If NO credentials found, return an EMPTY list.\n\n"
|
|
401
454
|
f"Command: {cmd}"
|
|
402
455
|
)
|
|
403
456
|
|
|
@@ -1858,3 +1858,110 @@ class TestDeduplicateDetections:
|
|
|
1858
1858
|
detections = [(token, "AcmeApiToken", "JWT")]
|
|
1859
1859
|
result = _deduplicate_detections(detections, cmd)
|
|
1860
1860
|
assert result[0][1] == "ACME_API_TOKEN"
|
|
1861
|
+
|
|
1862
|
+
def test_extracts_value_from_flag_syntax(self):
|
|
1863
|
+
"""Bug A: AI returns '--password=secret' instead of just 'secret'."""
|
|
1864
|
+
from mem.variables import _deduplicate_detections
|
|
1865
|
+
|
|
1866
|
+
cmd = "mysql -u admin --password=S3cretP@ssw0rd123 mydb"
|
|
1867
|
+
detections = [("--password=S3cretP@ssw0rd123", "DB_PASSWORD", "password")]
|
|
1868
|
+
result = _deduplicate_detections(detections, cmd)
|
|
1869
|
+
assert len(result) == 1
|
|
1870
|
+
assert result[0][0] == "S3cretP@ssw0rd123"
|
|
1871
|
+
|
|
1872
|
+
def test_extracts_value_from_env_var_syntax(self):
|
|
1873
|
+
"""Bug A: AI returns 'GITHUB_TOKEN=ghp_abc...' instead of just the token."""
|
|
1874
|
+
from mem.variables import _deduplicate_detections
|
|
1875
|
+
|
|
1876
|
+
token = "ghp_abcdefghijklmnopqrstuvwxyz123456"
|
|
1877
|
+
cmd = f"GITHUB_TOKEN={token} gh pr create"
|
|
1878
|
+
detections = [(f"GITHUB_TOKEN={token}", "GITHUB_TOKEN", "API token")]
|
|
1879
|
+
result = _deduplicate_detections(detections, cmd)
|
|
1880
|
+
assert len(result) == 1
|
|
1881
|
+
assert result[0][0] == token
|
|
1882
|
+
|
|
1883
|
+
def test_filters_hostnames(self):
|
|
1884
|
+
"""Bug C: hostnames like registry.ghcr.io are not credentials."""
|
|
1885
|
+
from mem.variables import _deduplicate_detections
|
|
1886
|
+
|
|
1887
|
+
cmd = "docker login -u deploy -p secret123456789 registry.ghcr.io"
|
|
1888
|
+
detections = [
|
|
1889
|
+
("secret123456789", "REGISTRY_TOKEN", "token"),
|
|
1890
|
+
("registry.ghcr.io", "REGISTRY_URL", "hostname"),
|
|
1891
|
+
]
|
|
1892
|
+
result = _deduplicate_detections(detections, cmd)
|
|
1893
|
+
assert len(result) == 1
|
|
1894
|
+
assert result[0][0] == "secret123456789"
|
|
1895
|
+
|
|
1896
|
+
def test_filters_ip_addresses(self):
|
|
1897
|
+
from mem.variables import _deduplicate_detections
|
|
1898
|
+
|
|
1899
|
+
cmd = "redis-cli -h 10.0.1.50 -a MyR3d1sPassword123 PING"
|
|
1900
|
+
detections = [
|
|
1901
|
+
("MyR3d1sPassword123", "REDIS_PASSWORD", "password"),
|
|
1902
|
+
("10.0.1.50", "REDIS_HOST", "IP address"),
|
|
1903
|
+
]
|
|
1904
|
+
result = _deduplicate_detections(detections, cmd)
|
|
1905
|
+
assert len(result) == 1
|
|
1906
|
+
assert result[0][0] == "MyR3d1sPassword123"
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
class TestExtractValueFromSyntax:
|
|
1910
|
+
"""Unit tests for _extract_value_from_syntax."""
|
|
1911
|
+
|
|
1912
|
+
def test_cli_flag_long(self):
|
|
1913
|
+
from mem.variables import _extract_value_from_syntax
|
|
1914
|
+
|
|
1915
|
+
cmd = "mysql --password=secret123 mydb"
|
|
1916
|
+
assert _extract_value_from_syntax("--password=secret123", cmd) == "secret123"
|
|
1917
|
+
|
|
1918
|
+
def test_cli_flag_short(self):
|
|
1919
|
+
from mem.variables import _extract_value_from_syntax
|
|
1920
|
+
|
|
1921
|
+
cmd = "tool -p=myvalue123 arg"
|
|
1922
|
+
assert _extract_value_from_syntax("-p=myvalue123", cmd) == "myvalue123"
|
|
1923
|
+
|
|
1924
|
+
def test_env_var_assignment(self):
|
|
1925
|
+
from mem.variables import _extract_value_from_syntax
|
|
1926
|
+
|
|
1927
|
+
cmd = "GITHUB_TOKEN=ghp_abc123 gh pr create"
|
|
1928
|
+
assert _extract_value_from_syntax("GITHUB_TOKEN=ghp_abc123", cmd) == "ghp_abc123"
|
|
1929
|
+
|
|
1930
|
+
def test_plain_value_unchanged(self):
|
|
1931
|
+
from mem.variables import _extract_value_from_syntax
|
|
1932
|
+
|
|
1933
|
+
cmd = "curl -H 'Bearer eyJtoken'"
|
|
1934
|
+
assert _extract_value_from_syntax("eyJtoken", cmd) == "eyJtoken"
|
|
1935
|
+
|
|
1936
|
+
def test_value_not_in_cmd_returns_original(self):
|
|
1937
|
+
from mem.variables import _extract_value_from_syntax
|
|
1938
|
+
|
|
1939
|
+
cmd = "echo hello"
|
|
1940
|
+
assert _extract_value_from_syntax("--flag=nothere", cmd) == "--flag=nothere"
|
|
1941
|
+
|
|
1942
|
+
|
|
1943
|
+
class TestLooksLikeHostname:
|
|
1944
|
+
def test_domain(self):
|
|
1945
|
+
from mem.variables import _looks_like_hostname
|
|
1946
|
+
|
|
1947
|
+
assert _looks_like_hostname("registry.ghcr.io") is True
|
|
1948
|
+
|
|
1949
|
+
def test_subdomain(self):
|
|
1950
|
+
from mem.variables import _looks_like_hostname
|
|
1951
|
+
|
|
1952
|
+
assert _looks_like_hostname("api.prod.internal.company.com") is True
|
|
1953
|
+
|
|
1954
|
+
def test_ip_address(self):
|
|
1955
|
+
from mem.variables import _looks_like_hostname
|
|
1956
|
+
|
|
1957
|
+
assert _looks_like_hostname("10.0.1.50") is True
|
|
1958
|
+
|
|
1959
|
+
def test_token_not_hostname(self):
|
|
1960
|
+
from mem.variables import _looks_like_hostname
|
|
1961
|
+
|
|
1962
|
+
assert _looks_like_hostname("eyJhbGciOiJIUzI1NiJ9") is False
|
|
1963
|
+
|
|
1964
|
+
def test_password_not_hostname(self):
|
|
1965
|
+
from mem.variables import _looks_like_hostname
|
|
1966
|
+
|
|
1967
|
+
assert _looks_like_hostname("S3cretP@ssw0rd") is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|