diff --git a/.gitignore b/.gitignore index db2eeae..d13a666 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,10 @@ __pycache__/ *.pyc .DS_Store Thumbs.db + +# Personal lore store (`personalStoreDir` in .lore.json) — per-person notes about +# developing this plugin, read by recall alongside the team store but never +# published. `learnings/` is deliberately NOT ignored here: the team store is +# committed and shared by default, and that default is what users of the plugin +# get. +.lore/ diff --git a/.lore.json b/.lore.json new file mode 100644 index 0000000..b8ce356 --- /dev/null +++ b/.lore.json @@ -0,0 +1,3 @@ +{ + "personalStoreDir": ".lore/personal" +} diff --git a/plugin/scripts/scan_secrets.py b/plugin/scripts/scan_secrets.py index c78b159..effde5e 100644 --- a/plugin/scripts/scan_secrets.py +++ b/plugin/scripts/scan_secrets.py @@ -51,10 +51,18 @@ ("JWT", r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]{10,}\b"), ("Bearer token", r"(?i)\bbearer\s+[A-Za-z0-9_\-\.=]{20,}"), ("Credentials in URL", r"\b[a-z][a-z0-9+.\-]*://[^\s:@/]+:([^\s:@/]{3,})@[^\s/]+"), - # secret-ish assignment with a real-looking value (placeholders filtered below) + # secret-ish assignment with a real-looking value (placeholders filtered below). + # The keyword is deliberately NOT wrapped in `\b`: `_` is a word character, so + # `\b` never fires between an underscore and a letter, and `db_password`, + # `STRIPE_SECRET` and `aws_secret_access_key` — the dominant real-world + # spellings — all slipped through. Affixes are flat character classes rather + # than nested quantifiers, so backtracking stays linear. + # The value must stay capture group 1: `_is_placeholder` reads `group(1)`. ("Hardcoded secret assignment", - r"(?i)\b(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|" - r"auth[_-]?token|client[_-]?secret|private[_-]?key)\b\s*[:=]\s*" + r"(?i)(?:^|[^A-Za-z0-9])[A-Za-z0-9_.-]{0,40}" + r"(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|" + r"auth[_-]?token|client[_-]?secret|private[_-]?key)" + r"[A-Za-z0-9_.-]{0,40}\s*[:=]\s*" r"['\"]?([^\s'\"]{6,})['\"]?"), ] RULES = [(name, re.compile(pat)) for name, pat in _RULES] diff --git a/tests/test_scan_secrets.py b/tests/test_scan_secrets.py index bf5ca26..ca1322d 100644 --- a/tests/test_scan_secrets.py +++ b/tests/test_scan_secrets.py @@ -36,6 +36,26 @@ def test_real_looking_assignment_flagged(self): found = _scan('password = "h8s7d6f5g4h3j2k1"') self.assertEqual(len(found), 1) + def test_prefixed_secret_names_flagged(self): + # `\b` cannot fire between `_` and a letter, so an earlier version of this + # rule missed every prefixed spelling -- which is most of them in practice. + for line in ('db_password = "h8s7d6f5g4h3j2k1"', + 'my_api_key = "AbCd1234EfGh5678"', + 'STRIPE_SECRET = "AbCd1234EfGh5678"', + 'aws_secret_access_key = "wJalrXUtnFEMI7K7MDENGbPxRfiCYEX"', + "DATABASE_PASSWORD=s3cr3tp4ssw0rd"): + with self.subTest(line=line): + self.assertEqual(len(_scan(line)), 1) + + def test_prefixed_placeholder_still_not_flagged(self): + # Widening the name must not cost the placeholder filter its job. + self.assertEqual(_scan("db_password = changeme"), []) + self.assertEqual(_scan('my_api_key = "your_key_here"'), []) + + def test_short_or_nonsecret_values_not_flagged(self): + self.assertEqual(_scan("password: x"), []) + self.assertEqual(_scan("secret_name = db"), []) + def test_inline_allow(self): fake = "AKIA" + "C" * 16 self.assertEqual(_scan(f"{fake} lore:allow-secret"), [])