@hoanghainh/komagu 2.3.0 → 2.4.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/CHANGELOG.md +12 -0
- package/assess/lsa/CHANGELOG.md +15 -0
- package/assess/lsa/scripts/detect_stack.py +2 -1
- package/assess/lsa/scripts/lsa_common.py +1 -5
- package/assess/lsa/scripts/redaction.py +118 -0
- package/assess/lsa/tests/test_modern_stacks.py +13 -0
- package/operate/ai/context.py +22 -2
- package/operate/cli/__init__.py +1 -1
- package/operate/tests/test_ai_redaction.py +181 -0
- package/operate/tests/test_headless_ai.py +16 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@hoanghainh/komagu` (published as `komainu-guard` up to v1.7.0) are documented here. From v1.2.2 on, entries are generated by [release-please](https://github.com/googleapis/release-please) from [Conventional Commits](https://www.conventionalcommits.org/). Earlier entries are copied from the [GitHub Releases](https://github.com/hoanghainh1188/komainu/releases).
|
|
4
4
|
|
|
5
|
+
## [2.4.0](https://github.com/hoanghainh1188/komagu/compare/v2.3.0...v2.4.0) (2026-11-03)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **pilot:** prepare the AMS pilot on a Java legacy system and redact secrets sent to the model ([#352](https://github.com/hoanghainh1188/komagu/issues/352)) ([fb02b3c](https://github.com/hoanghainh1188/komagu/commit/fb02b3cbdf70e3d6e5ee7c73c7e308f03c2ff7f3))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Documentation
|
|
14
|
+
|
|
15
|
+
* **delivery:** remove Komainu leftovers and file:/// links from pilot deliverables ([#353](https://github.com/hoanghainh1188/komagu/issues/353)) ([f61d1df](https://github.com/hoanghainh1188/komagu/commit/f61d1dff03469986970d0a6d640a37fd2f7b947f))
|
|
16
|
+
|
|
5
17
|
## [2.3.0](https://github.com/hoanghainh1188/komagu/compare/v2.2.1...v2.3.0) (2026-11-01)
|
|
6
18
|
|
|
7
19
|
|
package/assess/lsa/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
AMS pilot readiness (#350).
|
|
6
|
+
|
|
7
|
+
- **Secret redaction (`scripts/redaction.py`, re-exported by `lsa_common`):** besides quoted `password`-like values, it now masks:
|
|
8
|
+
- unquoted config values (`.properties`, YAML, `.env`) and dotted keys inside comments (`<%-- db.password=x --%>`);
|
|
9
|
+
- JSON and PHP keys, plus `pass`, `pw`, `passphrase` and `credential` key names such as Tomcat `keystorePass`;
|
|
10
|
+
- Java legacy forms: `setPassword("x")`, `getConnection(url, user, "x")`, `PasswordAuthentication`, Spring XML `<property name=… value=…>`, and `web.xml` `<param-name>` / `<param-value>`, including when split across lines;
|
|
11
|
+
- credentials in connection strings and URIs (`;password=`, `?pwd=`, `password={…}`, `scheme://user:pass@host`, `jdbc:oracle:thin:user/pass@host`);
|
|
12
|
+
- `Basic` / `Bearer` values and AWS, GitHub, `sk-` and Google API tokens;
|
|
13
|
+
- private key blocks.
|
|
14
|
+
|
|
15
|
+
Lines longer than 4,000 characters are dropped whole, which keeps the regexes linear on minified files. The headless runner applies `redact_lines()` to every code excerpt it sends to the model (`operate/ai/context.py`).
|
|
16
|
+
- **`detect_stack.py`:** JPA's `@Entity` and Spring Data's `getRepository` no longer report "TypeORM (Node/TS)" on Java code.
|
|
17
|
+
|
|
3
18
|
## 0.3.1 — 2026-10-30
|
|
4
19
|
|
|
5
20
|
SARIF replay properties (#289).
|
|
@@ -86,7 +86,8 @@ DATA_ACCESS = [
|
|
|
86
86
|
("database/sql (Go)", r"sql\.Open|db\.Query|db\.Exec"),
|
|
87
87
|
("SQLAlchemy (Python)", r"sqlalchemy\.|create_engine|SessionLocal"),
|
|
88
88
|
("Prisma (Node/TS)", r"PrismaClient|@prisma/client"),
|
|
89
|
-
|
|
89
|
+
# Not bare @Entity / getRepository: JPA and Spring Data use the same names
|
|
90
|
+
("TypeORM (Node/TS)", r"['\"]typeorm['\"]|\bTypeORM\b"),
|
|
90
91
|
("Drizzle ORM (Node/TS)", r"drizzle-orm"),
|
|
91
92
|
("postgres.js (Node/TS)", r"from\s+['\"]postgres['\"]|require\(\s*['\"]postgres['\"]\s*\)"),
|
|
92
93
|
]
|
|
@@ -166,8 +166,4 @@ def snippet(lines: List[str], lineno: int, max_len: int = 220) -> str:
|
|
|
166
166
|
return s if len(s) <= max_len else s[: max_len - 3] + "..."
|
|
167
167
|
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def redact(s: str) -> str:
|
|
173
|
-
return SECRET_RE.sub(lambda m: m.group(1) + "=[REDACTED]", s)
|
|
169
|
+
from redaction import redact, redact_lines # noqa: E402,F401 (re-exported for the Tier 1 scripts)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Secret redaction for code excerpts: Tier 1 snippets and every excerpt the headless agents send to a model (#350).
|
|
2
|
+
|
|
3
|
+
Pattern-based, so it cannot catch every secret: the pilot runbook also leaves key and environment config files
|
|
4
|
+
out of the source copy. Standard-library only (Python 3.8+ compatible).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
MASK = "[REDACTED]"
|
|
13
|
+
# Longer lines (minified bundles, generated data) are dropped whole: they are not useful to an auditor and
|
|
14
|
+
# keep the regexes below from running on megabyte-long input
|
|
15
|
+
MAX_LINE_CHARS = 4000
|
|
16
|
+
|
|
17
|
+
# A secret-like key name; the lookahead keeps passwordPolicy, tokenizer and Passenger out
|
|
18
|
+
_KW = r"(?:password|passwd|passphrase|pwd|pw|pass|secret|credential|api[_-]?key|token)(?![a-z])"
|
|
19
|
+
_NAME_TAGS = r"(?:param-name|name|key)"
|
|
20
|
+
_VALUE_TAGS = r"(?:param-value|value)"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _rx(pattern: str, flags: int = re.IGNORECASE) -> re.Pattern[str]:
|
|
24
|
+
return re.compile(pattern, flags)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# (pattern, replacement); applied in order to each line
|
|
28
|
+
_RULES: Tuple[Tuple[re.Pattern[str], str], ...] = (
|
|
29
|
+
# -----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY----- on one line
|
|
30
|
+
(_rx(r"(-----BEGIN [A-Z ]*PRIVATE KEY-----).*?(-----END [A-Z ]*PRIVATE KEY-----)", 0), r"\1" + MASK + r"\2"),
|
|
31
|
+
# "password": "x", 'password' => 'x', pw = "x", keystorePass="x", setProperty("mail.smtp.password", "x")
|
|
32
|
+
(_rx(r"(?<![\w.\-])((['\"]?)[\w.\-]*?" + _KW + r"\2\s*(?:=>|->|[=:,])\s*)(['\"])[^'\"]+\3"), r"\1\3" + MASK + r"\3"),
|
|
33
|
+
# conn.setPassword("x"), setKeystorePass("x")
|
|
34
|
+
(_rx(r"(\bset\w*?" + _KW + r"\s*\(\s*)(['\"])[^'\"]*\2"), r"\1\2" + MASK + r"\2"),
|
|
35
|
+
# DriverManager.getConnection(url, "sa", "x")
|
|
36
|
+
(_rx(r"(\bgetConnection\s*\([^,()]*,[^,()]*,\s*)(['\"])[^'\"]*\2"), r"\1\2" + MASK + r"\2"),
|
|
37
|
+
# new PasswordAuthentication("u", "x".toCharArray())
|
|
38
|
+
(_rx(r"(\bPasswordAuthentication\s*\([^,()]*,\s*)(['\"])[^'\"]*\2"), r"\1\2" + MASK + r"\2"),
|
|
39
|
+
# <property name="jdbc.password" value="x"/>
|
|
40
|
+
(_rx(r"(\bname\s*=\s*['\"][^'\"]*?" + _KW + r"[^'\"]*['\"][^>]*?\bvalue\s*=\s*)(['\"])[^'\"]*\2"),
|
|
41
|
+
r"\1\2" + MASK + r"\2"),
|
|
42
|
+
# <param-name>password</param-name><param-value>x</param-value>
|
|
43
|
+
(_rx(r"(<" + _NAME_TAGS + r">[^<]*?" + _KW + r"[^<]*</" + _NAME_TAGS + r">\s*<" + _VALUE_TAGS + r">)[^<]+"),
|
|
44
|
+
r"\1" + MASK),
|
|
45
|
+
# <password>x</password>
|
|
46
|
+
(_rx(r"(<([\w.\-:]*?" + _KW + r")\s*>)[^<]+(?=</\2\s*>)"), r"\1" + MASK),
|
|
47
|
+
# jdbc:sqlserver://h;password=x ?pwd=x& password={a;b}
|
|
48
|
+
(_rx(r"([;?&](?:password|passwd|pwd)=)(?:\{[^}]*\}|[^;\"'&\s]+)"), r"\1" + MASK),
|
|
49
|
+
# scheme://user:pass@host
|
|
50
|
+
(_rx(r"(?<![a-z0-9+.\-])([a-z][a-z0-9+.\-]*://[^/\s:@\"']+:)[^@\s\"'/]+@"), r"\1" + MASK + "@"),
|
|
51
|
+
# jdbc:oracle:thin:user/pass@host
|
|
52
|
+
(_rx(r"(\bjdbc:oracle:\w+:[^/\s:@\"']+/)[^@\s\"']+@"), r"\1" + MASK + "@"),
|
|
53
|
+
# Authorization: Basic xxx / Bearer xxx
|
|
54
|
+
(_rx(r"(\b(?:Basic|Bearer)\s+)[A-Za-z0-9._~+/=\-]{8,}"), r"\1" + MASK),
|
|
55
|
+
# Well-known token formats (case-sensitive)
|
|
56
|
+
(_rx(r"\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_\w{20,}|sk-[A-Za-z0-9_\-]{20,}"
|
|
57
|
+
r"|AIza[0-9A-Za-z_\-]{35})\b", 0), MASK),
|
|
58
|
+
# Unquoted config values (.properties, YAML, .env) on their own line; ${PLACEHOLDER} and code are kept
|
|
59
|
+
(_rx(r"^(\s*[\w.\-]*?" + _KW + r"[\w.\-]*\s*[=:]\s*)(?![\"'$=\s])[^\s\"'(){}<>,;]+\s*(?:(?:#|//).*)?$"), r"\1" + MASK),
|
|
60
|
+
# Dotted config keys inside comments or strings: <%-- db.password=x --%>
|
|
61
|
+
(_rx(r"(?<![\w.\-])([\w\-]+\.[\w.\-]*?" + _KW + r"[\w\-]*\s*=\s*)(?![\"'$=\s])[^\s\"'(){}<>,;]+(?=\s|$|[\"'])"),
|
|
62
|
+
r"\1" + MASK),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_PRIVATE_KEY_BEGIN = _rx(r"-----BEGIN [A-Z ]*PRIVATE KEY-----", 0)
|
|
66
|
+
_PRIVATE_KEY_END = _rx(r"-----END [A-Z ]*PRIVATE KEY-----", 0)
|
|
67
|
+
# A secret name whose value follows on a later line: <property name="password"\n value="x"/>, web.xml params
|
|
68
|
+
_PENDING_NAME = _rx(r"\bname\s*=\s*['\"][^'\"]*?" + _KW + r"[^'\"]*['\"]|<" + _NAME_TAGS + r">[^<]*?" + _KW)
|
|
69
|
+
_PENDING_VALUE: Tuple[Tuple[re.Pattern[str], str], ...] = (
|
|
70
|
+
(_rx(r"(\bvalue\s*=\s*)(['\"])[^'\"]*\2"), r"\1\2" + MASK + r"\2"),
|
|
71
|
+
(_rx(r"(<" + _VALUE_TAGS + r">)[^<]+"), r"\1" + MASK),
|
|
72
|
+
)
|
|
73
|
+
_PENDING_LINES = 2
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _too_long(s: str) -> Optional[str]:
|
|
77
|
+
return f"[line of {len(s)} characters omitted]" if len(s) > MAX_LINE_CHARS else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def redact(s: str) -> str:
|
|
81
|
+
"""Mask secrets in one line of code or config."""
|
|
82
|
+
omitted = _too_long(s)
|
|
83
|
+
if omitted:
|
|
84
|
+
return omitted
|
|
85
|
+
for pattern, repl in _RULES:
|
|
86
|
+
s = pattern.sub(repl, s)
|
|
87
|
+
return s
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _redact_pending(line: str) -> Tuple[str, bool]:
|
|
91
|
+
for pattern, repl in _PENDING_VALUE:
|
|
92
|
+
masked, n = pattern.subn(repl, line)
|
|
93
|
+
if n:
|
|
94
|
+
return masked, True
|
|
95
|
+
return line, False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def redact_lines(lines: List[str]) -> List[str]:
|
|
99
|
+
"""Redact each line, blank out private key bodies and mask values split from their names.
|
|
100
|
+
|
|
101
|
+
The line count stays the same, so line numbers in excerpts remain valid.
|
|
102
|
+
"""
|
|
103
|
+
out: List[str] = []
|
|
104
|
+
in_key, pending = False, 0
|
|
105
|
+
for line in lines:
|
|
106
|
+
if in_key:
|
|
107
|
+
in_key = not _PRIVATE_KEY_END.search(line)
|
|
108
|
+
out.append(MASK if in_key else line)
|
|
109
|
+
continue
|
|
110
|
+
masked = redact(line)
|
|
111
|
+
if pending:
|
|
112
|
+
masked, done = _redact_pending(masked)
|
|
113
|
+
pending = 0 if done else pending - 1
|
|
114
|
+
if _PENDING_NAME.search(line) and MASK not in masked:
|
|
115
|
+
pending = _PENDING_LINES
|
|
116
|
+
in_key = bool(_PRIVATE_KEY_BEGIN.search(line)) and not _PRIVATE_KEY_END.search(line)
|
|
117
|
+
out.append(masked)
|
|
118
|
+
return out
|
|
@@ -255,6 +255,19 @@ async fn f(pool: &PgPool, id: &str) {
|
|
|
255
255
|
self.assertEqual(raw, {4, 5})
|
|
256
256
|
|
|
257
257
|
# --- Medium: wrong output ----------------------------------------------------------
|
|
258
|
+
def test_jpa_entity_is_not_reported_as_typeorm(self) -> None:
|
|
259
|
+
with tempfile.TemporaryDirectory() as td:
|
|
260
|
+
root = Path(td)
|
|
261
|
+
(root / "Account.java").write_text(
|
|
262
|
+
'import javax.persistence.Entity;\n@Entity\npublic class Account { Long id; String role; }\n',
|
|
263
|
+
encoding="utf-8")
|
|
264
|
+
data_access = detect(root, self.config)["data_access"]
|
|
265
|
+
self.assertIn("Hibernate/JPA", data_access)
|
|
266
|
+
self.assertNotIn("TypeORM (Node/TS)", data_access)
|
|
267
|
+
(root / "user.entity.ts").write_text(
|
|
268
|
+
"import { Entity, Column } from 'typeorm';\n@Entity()\nexport class User {}\n", encoding="utf-8")
|
|
269
|
+
self.assertIn("TypeORM (Node/TS)", detect(root, self.config)["data_access"])
|
|
270
|
+
|
|
258
271
|
def test_go_stack_label_ignores_gin_substring(self) -> None:
|
|
259
272
|
routes, _ = self.scan({"main.go": '''package main
|
|
260
273
|
import "github.com/labstack/echo/v4"
|
package/operate/ai/context.py
CHANGED
|
@@ -5,18 +5,38 @@ Standard-library only (Python 3.8+ compatible).
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import importlib.util
|
|
8
9
|
import re
|
|
9
10
|
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
10
12
|
from typing import Any, Dict, List, Optional
|
|
11
13
|
|
|
14
|
+
LSA_REDACTION = Path(__file__).resolve().parents[2] / "assess" / "lsa" / "scripts" / "redaction.py"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_redaction() -> ModuleType:
|
|
18
|
+
"""Load the LSA kit's stdlib-only redaction module, so Tier 1 and the agents share one set of patterns."""
|
|
19
|
+
spec = importlib.util.spec_from_file_location("komagu_lsa_redaction", LSA_REDACTION)
|
|
20
|
+
if spec is None or spec.loader is None:
|
|
21
|
+
raise ImportError(f"cannot load LSA redaction from {LSA_REDACTION}")
|
|
22
|
+
module = importlib.util.module_from_spec(spec)
|
|
23
|
+
spec.loader.exec_module(module)
|
|
24
|
+
return module
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_redact_lines = _load_redaction().redact_lines
|
|
28
|
+
|
|
12
29
|
|
|
13
30
|
def _read_snippet(target_dir: Path, rel_file: str, line: int, radius: int = 15) -> str:
|
|
14
|
-
"""Read a window of code around target line from the audited repository.
|
|
31
|
+
"""Read a window of code around target line from the audited repository, with secrets redacted.
|
|
32
|
+
|
|
33
|
+
Every code excerpt sent to the model goes through here (#350).
|
|
34
|
+
"""
|
|
15
35
|
full_path = target_dir / rel_file
|
|
16
36
|
if not full_path.is_file():
|
|
17
37
|
return f"[File not found: {rel_file}]"
|
|
18
38
|
try:
|
|
19
|
-
lines = full_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
39
|
+
lines = _redact_lines(full_path.read_text(encoding="utf-8", errors="replace").splitlines())
|
|
20
40
|
start = max(0, line - radius - 1)
|
|
21
41
|
end = min(len(lines), line + radius)
|
|
22
42
|
numbered = [f"{i + 1:4d} | {lines[i]}" for i in range(start, end)]
|
package/operate/cli/__init__.py
CHANGED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Secrets are masked in every code snippet the headless agents send to the model (#350)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from operate.ai.context import _model_source, _read_snippet, _redact_lines, _verification_context
|
|
14
|
+
|
|
15
|
+
JAVA_DAO = '''package com.acme.dao;
|
|
16
|
+
public class AccountDao {
|
|
17
|
+
private static final String URL = "jdbc:sqlserver://db01;databaseName=ams;user=sa;password=Ams#2019!";
|
|
18
|
+
private static final String MONGO = "mongodb://svc:Mongo2020pw@mongo01:27017/ams";
|
|
19
|
+
private static final String ORA = "jdbc:oracle:thin:scott/Tiger2018@db02:1521/AMS";
|
|
20
|
+
// db.password=CommentPw7 (old config, kept for reference)
|
|
21
|
+
private String apiKey = "sk-live-abcdef123456";
|
|
22
|
+
public Account find(HttpServletRequest req) {
|
|
23
|
+
String password = req.getParameter("password");
|
|
24
|
+
String sql = "SELECT * FROM account WHERE id = " + req.getParameter("id");
|
|
25
|
+
return jdbc.query(sql);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
'''
|
|
29
|
+
|
|
30
|
+
PROPERTIES = '''spring.datasource.url=jdbc:oracle:thin:@db02:1521/AMS
|
|
31
|
+
spring.datasource.username=ams_app
|
|
32
|
+
spring.datasource.password=Oracle#Pw2021
|
|
33
|
+
mail.smtp.password: smtpPw99
|
|
34
|
+
app.jwt.secret=c2VjcmV0LWtleS1mb3ItYW1z
|
|
35
|
+
app.token.placeholder=${APP_TOKEN}
|
|
36
|
+
'''
|
|
37
|
+
|
|
38
|
+
PRIVATE_KEY = '''-----BEGIN RSA PRIVATE KEY-----
|
|
39
|
+
MIIEowIBAAKCAQEAwJ1rdTw6kLqA
|
|
40
|
+
Zm9vYmFyYmF6cXV4cXV1eHF1dXg=
|
|
41
|
+
-----END RSA PRIVATE KEY-----
|
|
42
|
+
class Signer {}
|
|
43
|
+
'''
|
|
44
|
+
|
|
45
|
+
JSP = '''<%-- legacy: ldap.bind.password=LdapPw55 --%>
|
|
46
|
+
<%@ page import="java.sql.*" %>
|
|
47
|
+
<% String id = request.getParameter("id"); %>
|
|
48
|
+
'''
|
|
49
|
+
|
|
50
|
+
SECRETS = ("Ams#2019!", "Mongo2020pw", "Tiger2018", "CommentPw7", "sk-live-abcdef123456", "Oracle#Pw2021", "smtpPw99",
|
|
51
|
+
"c2VjcmV0LWtleS1mb3ItYW1z", "MIIEowIBAAKCAQEAwJ1rdTw6kLqA", "Zm9vYmFyYmF6cXV4cXV1eHF1dXg=", "LdapPw55")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestSnippetRedaction(unittest.TestCase):
|
|
55
|
+
def setUp(self) -> None:
|
|
56
|
+
self.target = Path(tempfile.mkdtemp(prefix="komagu_redact_"))
|
|
57
|
+
files = {"src/com/acme/dao/AccountDao.java": JAVA_DAO, "src/main/resources/application.properties": PROPERTIES,
|
|
58
|
+
"src/com/acme/Signer.java": PRIVATE_KEY, "WebContent/user/detail.jsp": JSP}
|
|
59
|
+
for rel, text in files.items():
|
|
60
|
+
(self.target / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
(self.target / rel).write_text(text, encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
def tearDown(self) -> None:
|
|
64
|
+
shutil.rmtree(self.target, ignore_errors=True)
|
|
65
|
+
|
|
66
|
+
def assert_no_secret(self, text: str) -> None:
|
|
67
|
+
for secret in SECRETS:
|
|
68
|
+
self.assertNotIn(secret, text)
|
|
69
|
+
|
|
70
|
+
def test_java_source_snippet_masks_connection_strings_and_keys(self) -> None:
|
|
71
|
+
snip = _read_snippet(self.target, "src/com/acme/dao/AccountDao.java", 5)
|
|
72
|
+
self.assert_no_secret(snip)
|
|
73
|
+
self.assertIn("[REDACTED]", snip)
|
|
74
|
+
|
|
75
|
+
def test_code_the_auditor_needs_is_kept(self) -> None:
|
|
76
|
+
snip = _read_snippet(self.target, "src/com/acme/dao/AccountDao.java", 5)
|
|
77
|
+
self.assertIn('String password = req.getParameter("password");', snip)
|
|
78
|
+
self.assertIn('"SELECT * FROM account WHERE id = " + req.getParameter("id")', snip)
|
|
79
|
+
self.assertIn("jdbc:sqlserver://db01;databaseName=ams;user=sa;password=[REDACTED]", snip)
|
|
80
|
+
self.assertIn("mongodb://svc:[REDACTED]@mongo01:27017/ams", snip)
|
|
81
|
+
self.assertIn("jdbc:oracle:thin:scott/[REDACTED]@db02:1521/AMS", snip)
|
|
82
|
+
|
|
83
|
+
def test_config_line_inside_a_comment_is_masked(self) -> None:
|
|
84
|
+
snip = _read_snippet(self.target, "WebContent/user/detail.jsp", 2)
|
|
85
|
+
self.assert_no_secret(snip)
|
|
86
|
+
self.assertIn("ldap.bind.password=[REDACTED] --%>", snip)
|
|
87
|
+
self.assertIn('String id = request.getParameter("id");', snip)
|
|
88
|
+
|
|
89
|
+
def test_assignments_in_code_are_kept(self) -> None:
|
|
90
|
+
lines = ["user.password = req.body.password;", "if (token == null) return;", "token: req.headers.authorization,"]
|
|
91
|
+
(self.target / "app.js").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
92
|
+
snip = _read_snippet(self.target, "app.js", 2)
|
|
93
|
+
for line in lines:
|
|
94
|
+
self.assertIn(line, snip)
|
|
95
|
+
|
|
96
|
+
def test_unquoted_properties_values_are_masked(self) -> None:
|
|
97
|
+
snip = _read_snippet(self.target, "src/main/resources/application.properties", 3)
|
|
98
|
+
self.assert_no_secret(snip)
|
|
99
|
+
self.assertIn("spring.datasource.username=ams_app", snip)
|
|
100
|
+
self.assertIn("app.token.placeholder=${APP_TOKEN}", snip)
|
|
101
|
+
|
|
102
|
+
def test_private_key_block_is_masked_with_line_numbers_kept(self) -> None:
|
|
103
|
+
snip = _read_snippet(self.target, "src/com/acme/Signer.java", 3)
|
|
104
|
+
self.assert_no_secret(snip)
|
|
105
|
+
self.assertIn(" 5 | class Signer {}", snip)
|
|
106
|
+
|
|
107
|
+
def test_model_and_verifier_context_are_masked(self) -> None:
|
|
108
|
+
model = _model_source(self.target, "AccountDao", "src/com/acme/dao/AccountDao.java", 1)
|
|
109
|
+
self.assert_no_secret(model["code"])
|
|
110
|
+
ctx = _verification_context(self.target, {"location": {"file": "src/com/acme/dao/AccountDao.java", "start_line": 8},
|
|
111
|
+
"evidence": [{"file": "src/main/resources/application.properties", "line": 3}]})
|
|
112
|
+
self.assert_no_secret(str(ctx))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestJavaLegacySecretForms(unittest.TestCase):
|
|
116
|
+
"""Forms found in a security review of the first version (#350); each must hide Hunter2x."""
|
|
117
|
+
|
|
118
|
+
LEAKS = (
|
|
119
|
+
'<property name="password" value="Hunter2x"/>',
|
|
120
|
+
'<property name="jdbc.password"\n value="Hunter2x"/>',
|
|
121
|
+
"<param-name>db.password</param-name><param-value>Hunter2x</param-value>",
|
|
122
|
+
"<init-param>\n <param-name>password</param-name>\n <param-value>Hunter2x</param-value>",
|
|
123
|
+
"<password>Hunter2x</password>",
|
|
124
|
+
'conn.setPassword("Hunter2x");',
|
|
125
|
+
'props.setProperty("mail.smtp.password", "Hunter2x");',
|
|
126
|
+
'Connection c = DriverManager.getConnection(url, "sa", "Hunter2x");',
|
|
127
|
+
'return new PasswordAuthentication("svc", "Hunter2x".toCharArray());',
|
|
128
|
+
'String pw = "Hunter2x";',
|
|
129
|
+
'static final String DB_PASS = "Hunter2x";',
|
|
130
|
+
'<Connector port="8443" keystorePass="Hunter2x" truststorePass="Hunter2x"/>',
|
|
131
|
+
'{"password":"Hunter2x", "apikey" : "Hunter2x"}',
|
|
132
|
+
"$conf = array('password' => 'Hunter2x');",
|
|
133
|
+
"db_password: Hunter2x # prod",
|
|
134
|
+
"mail.smtp.pass=Hunter2x",
|
|
135
|
+
"ssl.key.passphrase=Hunter2x",
|
|
136
|
+
"jdbc:sqlserver://db;user=sa;password={Hunter2x;x}",
|
|
137
|
+
'conn.setRequestProperty("Authorization", "Basic SHVudGVyMng6eHl6");',
|
|
138
|
+
'h.put("Authorization", "Bearer Hunter2xHunter2x.eyJzdWIiOiIxIn0.sig");',
|
|
139
|
+
"-----BEGIN PRIVATE KEY----- Hunter2xMIIEvQIBADANBgkq -----END PRIVATE KEY-----",
|
|
140
|
+
)
|
|
141
|
+
TOKENS = ("AKIAIOSFODNN7EXAMPLE", "ghp_" + "a" * 36, "sk-ant-api03-" + "b" * 40, "AIza" + "c" * 35)
|
|
142
|
+
KEPT = (
|
|
143
|
+
'String password = req.getParameter("password");',
|
|
144
|
+
"String sql = \"SELECT * FROM users WHERE login='\" + u + \"' AND pwd='\" + p + \"'\";",
|
|
145
|
+
"<select id=\"login\">SELECT * FROM users WHERE pwd = #{password}</select>",
|
|
146
|
+
"spring.datasource.password=${DB_PASS}",
|
|
147
|
+
"user.password = req.body.password;",
|
|
148
|
+
"if (token == null) return;",
|
|
149
|
+
'String header = "Bearer " + token;',
|
|
150
|
+
'<property name="username" value="ams_app"/>',
|
|
151
|
+
"Passenger p = passengerDao.find(id);",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def redact(self, text: str) -> str:
|
|
155
|
+
return "\n".join(_redact_lines(text.split("\n")))
|
|
156
|
+
|
|
157
|
+
def test_java_legacy_forms_are_masked(self) -> None:
|
|
158
|
+
for sample in self.LEAKS:
|
|
159
|
+
with self.subTest(sample=sample):
|
|
160
|
+
self.assertNotIn("Hunter2x", self.redact(sample))
|
|
161
|
+
|
|
162
|
+
def test_cloud_and_vcs_tokens_are_masked(self) -> None:
|
|
163
|
+
for token in self.TOKENS:
|
|
164
|
+
with self.subTest(token=token[:8]):
|
|
165
|
+
self.assertNotIn(token, self.redact(f'String key = cfg.get("{token}");'))
|
|
166
|
+
|
|
167
|
+
def test_code_the_auditor_needs_is_kept(self) -> None:
|
|
168
|
+
for line in self.KEPT:
|
|
169
|
+
with self.subTest(line=line):
|
|
170
|
+
self.assertEqual(self.redact(line), line)
|
|
171
|
+
|
|
172
|
+
def test_long_lines_are_fast_and_not_sent(self) -> None:
|
|
173
|
+
lines = ["a.b" * 300000 + "password", "a-" * 500000 + "://", "a-b." * 250000, "x" * 1000000]
|
|
174
|
+
start = time.monotonic()
|
|
175
|
+
out = _redact_lines(lines)
|
|
176
|
+
self.assertLess(time.monotonic() - start, 2.0)
|
|
177
|
+
self.assertTrue(all(o.startswith("[line of ") for o in out))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
unittest.main()
|
|
@@ -19,7 +19,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|
|
19
19
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
20
20
|
|
|
21
21
|
from operate.ai.client import LLMClient
|
|
22
|
-
from operate.ai.mock_server import start_mock_llm_server
|
|
22
|
+
from operate.ai.mock_server import MockLLMHandler, start_mock_llm_server
|
|
23
23
|
from operate.ai.runner import HeadlessAIRunner
|
|
24
24
|
from operate.cli.main import main as cli_main
|
|
25
25
|
|
|
@@ -295,6 +295,21 @@ module.exports = app;
|
|
|
295
295
|
self.assertEqual(stats["parse_failures"], {})
|
|
296
296
|
self.assertEqual(stats["anchoring"]["unanchored"], [])
|
|
297
297
|
|
|
298
|
+
def test_no_planted_secret_reaches_the_model(self) -> None:
|
|
299
|
+
planted = ("PlantedPg9", "planted-key-42", "PlantedOra3")
|
|
300
|
+
with (self.target_dir / "app.js").open("a", encoding="utf-8") as fh:
|
|
301
|
+
fh.write('const DB = "postgres://app:PlantedPg9@db/orders";\nconst apiKey = "planted-key-42";\n'
|
|
302
|
+
'// legacy: db.password=PlantedOra3\n')
|
|
303
|
+
self._prescan()
|
|
304
|
+
client = LLMClient(endpoint=self.base_url, model="mock-model", api_key="test")
|
|
305
|
+
HeadlessAIRunner(self.target_dir, self.out_dir, client).run()
|
|
306
|
+
|
|
307
|
+
sent = json.dumps(MockLLMHandler.received_requests)
|
|
308
|
+
self.assertIn("5-finding-verifier", json.loads((self.out_dir / "ai-run-stats.json").read_text())["phases"])
|
|
309
|
+
self.assertIn("[REDACTED]", sent)
|
|
310
|
+
for secret in planted:
|
|
311
|
+
self.assertNotIn(secret, sent)
|
|
312
|
+
|
|
298
313
|
def test_unparseable_verifier_reply_leaves_findings_unverified(self) -> None:
|
|
299
314
|
"""A broken verifier reply must not confirm findings (#286)."""
|
|
300
315
|
self.server.server_close()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hoanghainh/komagu",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Security-First Legacy Modernization Platform CLI (LSA Kit, IAM Perimeter, Cross-User Testing)",
|
|
5
5
|
"main": "remediate/authz/nodejs/src/index.js",
|
|
6
6
|
"types": "remediate/authz/nodejs/src/index.d.ts",
|