mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-05 20:32:05 +00:00
Mutate guards by what they DO, not just what they are about
The generator selected lines by an auth vocabulary — token, scope, expire, password. src/configFile.ts, whose validators are the API's only defence against a nonsense value being written permanently into an encrypted file, generated ZERO mutants: `if (n <= 0) throw new ConfigValueError(...)` is a refusal in every meaningful sense and contains not one word that list knows. The NOT MEASURED reporting added earlier is what surfaced it, on its first run against a new target. A guard whose body refuses is now selected on that basis, vocabulary or not. That takes the corpus from 132 mutants to 234 and it is the right trade: a refusal is the security surface, definitionally. It immediately found three gaps in the config validators written in the previous commit: - The `||` chain rejecting non-numbers was only tested with a string. Number(true) is 1 and Number(null) is 0, so `inactivity_timeout: true` became a one-second timeout and `clipboard_clear_delay: null` became zero — a setting silently changed to something nobody asked for. - `n < 0` versus `n <= 0` on the non-negative fields: zero was never tested, and it is a legitimate value that must not be conflated with negative. - The `kdf_iterations` floor had no test at all. Zero iterations is not a slow KDF, it is no KDF, in the config of a vault whose master password protects everything else. configFile.ts is registered as a mutation target so it stays measured. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
1 parent
33aeeead9c
commit
9c9544b725
2 files changed
+52
-3
No files matched your search
@@ -252,11 +252,40 @@ describe("what the API refuses", () => {
|
||||
["relays", "wss://one.example"],
|
||||
["relays", []],
|
||||
["min_uppercase", "lots"],
|
||||
// Values that LOOK numeric to Number() and are not numbers. Number(true)
|
||||
// is 1, Number(null) and Number("") are both 0, so without an explicit
|
||||
// check `inactivity_timeout: true` becomes a one-second timeout and
|
||||
// `clipboard_clear_delay: null` becomes zero — a setting silently
|
||||
// changed to something the caller never asked for.
|
||||
["inactivity_timeout", true],
|
||||
["clipboard_clear_delay", null],
|
||||
["min_uppercase", ""],
|
||||
["nostr_retry_delay", false],
|
||||
// Zero KDF iterations is not a slow KDF, it is no KDF — and it would be
|
||||
// written into the config of a vault whose master password protects
|
||||
// everything else.
|
||||
["kdf_iterations", 0],
|
||||
["kdf_iterations", -1],
|
||||
] as const) {
|
||||
const res = await call("PUT", `/api/v1/config/${key}`, { body: { value } });
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
|
||||
// Zero is not negative, and the two must not be conflated: a
|
||||
// clipboard delay of 0 is a real choice (clear on the next tick), while
|
||||
// a negative one is nonsense.
|
||||
expect(
|
||||
(await call("PUT", "/api/v1/config/clipboard_clear_delay", { body: { value: 0 } }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
expect((await call("GET", "/api/v1/config/clipboard_clear_delay")).json.value).toBe(0);
|
||||
// One iteration is pointless but it is a number the caller may legitimately
|
||||
// choose; the floor is where nonsense starts, not where good advice does.
|
||||
expect(
|
||||
(await call("PUT", "/api/v1/config/kdf_iterations", { body: { value: 1 } })).status,
|
||||
).toBe(200);
|
||||
await call("PUT", "/api/v1/config/kdf_iterations", { body: { value: 200000 } });
|
||||
|
||||
// And the well-typed versions still go through, so the check is
|
||||
// rejecting the value rather than the key.
|
||||
expect(
|
||||
|
||||
@@ -94,6 +94,10 @@ TARGETS: dict[str, tuple[str, list[str]]] = {
|
||||
"highrisk": ("src/highRisk.ts", ["test/highRisk.test.ts"]),
|
||||
"sinks": ("src/sinks.ts", ["test/sinks.test.ts", "test/tokens.test.ts"]),
|
||||
"approvals": ("src/approvals.ts", ["test/highRisk.test.ts"]),
|
||||
# The config normalizers are the API's only defence against a well-typed
|
||||
# nonsense value being stored permanently in an encrypted file nobody
|
||||
# reads back by hand.
|
||||
"config": ("src/configFile.ts", ["test/api.test.ts"]),
|
||||
}
|
||||
|
||||
# Operator swaps that change a decision rather than a value's shape.
|
||||
@@ -124,6 +128,13 @@ SECURITY_HINTS = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# A guard whose body REFUSES is security-relevant whether or not it happens to
|
||||
# use the vocabulary above. src/configFile.ts generated zero mutants because
|
||||
# its validators say `if (n <= 0) throw new ConfigValueError(...)` -- refusals
|
||||
# in every meaningful sense, containing not one word the hint list knows. This
|
||||
# catches them by what the line DOES rather than what it is about.
|
||||
REFUSAL = re.compile(r"\bthrow new \w*(Error|Exception)\b|\breturn \{ ?ok: false")
|
||||
|
||||
SKIP_LINE = re.compile(r"^\s*(//|\*|/\*)")
|
||||
|
||||
# A line preceded by this marker has been triaged as an EQUIVALENT mutant:
|
||||
@@ -157,7 +168,16 @@ def generate(source: str) -> list[Mutant]:
|
||||
mutants: list[Mutant] = []
|
||||
lines = source.splitlines()
|
||||
for i, line in enumerate(lines, start=1):
|
||||
if SKIP_LINE.match(line) or not SECURITY_HINTS.search(line):
|
||||
if SKIP_LINE.match(line):
|
||||
continue
|
||||
# A block-form guard puts its refusal on a following line, so look
|
||||
# ahead a little rather than only at the condition itself.
|
||||
block_refuses = any(REFUSAL.search(l) for l in lines[i : i + 3])
|
||||
if not (
|
||||
SECURITY_HINTS.search(line)
|
||||
or REFUSAL.search(line)
|
||||
or (line.rstrip().endswith("{") and block_refuses)
|
||||
):
|
||||
continue
|
||||
# Skip lines a human has already judged unkillable, with a reason.
|
||||
# Scans the whole contiguous comment block above, not just the line
|
||||
@@ -180,7 +200,7 @@ def generate(source: str) -> list[Mutant]:
|
||||
# entirely, that check is unverified.
|
||||
# Block form: `if (cond) {`
|
||||
guard = re.match(r"^(\s*)if \((.+)\) \{\s*$", line)
|
||||
if guard and SECURITY_HINTS.search(guard.group(2)):
|
||||
if guard and (SECURITY_HINTS.search(guard.group(2)) or block_refuses):
|
||||
mutants.append(
|
||||
Mutant(
|
||||
i,
|
||||
@@ -201,7 +221,7 @@ def generate(source: str) -> list[Mutant]:
|
||||
if (
|
||||
inline
|
||||
and not line.rstrip().endswith("{")
|
||||
and SECURITY_HINTS.search(inline.group(2))
|
||||
and (SECURITY_HINTS.search(inline.group(2)) or REFUSAL.search(inline.group(3)))
|
||||
):
|
||||
mutants.append(
|
||||
Mutant(
|
||||
|
||||
Reference in new issue
Block a user