Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/coverage-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ env:
TESTBED_REPO: profullstack/malware-test-prs
TESTBED_REF: f9f4fce8c0bd5e0391eca83658055c040ef222e0
# Floors, set just under the measured result at the pinned commit
# (TPR 65.9%, FPR 0%). The gap absorbs ordinary noise; a real regression —
# (TPR 71.3%, FPR 0%). The gap absorbs ordinary noise; a real regression —
# a rule that stops firing, or one that starts flagging the control group —
# moves the number past these and fails the job.
MIN_TPR: '60'
# moves the number past these and fails the job. Raise the floor with the
# rate: each rule set that lands should ratchet it up, not leave slack a
# regression could hide in.
MIN_TPR: '68'
MAX_FPR: '2'

jobs:
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/threatcrush",
"version": "0.8.0",
"version": "0.9.0",
"description": "All-in-one security agent daemon — monitor, detect, scan, and protect servers in real-time",
"bin": {
"threatcrush": "./dist/index.js"
Expand Down
2 changes: 1 addition & 1 deletion packages/scan/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@threatcrush/scan",
"version": "0.8.0",
"version": "0.9.0",
"description": "ThreatCrush scan rules and engine, shared by the CLI, web, desktop and extension.",
"license": "MIT",
"type": "module",
Expand Down
53 changes: 53 additions & 0 deletions packages/scan/src/__tests__/code-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,3 +692,56 @@ describe('language coverage', () => {
expect(claimed.length).toBeGreaterThanOrEqual(7);
});
});

describe('python weak crypto', () => {
it('flags a broken cipher and ECB mode, not a safe AES mode', () => {
expect(ruleIds('a.py', 'cipher = DES.new(DEMO_KEY_8, DES.MODE_ECB)')).toContain(
'py-broken-cipher',
);
expect(ruleIds('a.py', 'cipher = ARC4.new(DEMO_KEY_16)')).toContain('py-broken-cipher');
expect(ruleIds('a.py', 'cipher = AES.new(key, AES.MODE_ECB)')).toContain('py-broken-cipher');
expect(ruleIds('a.py', 'cipher = AES.new(key, AES.MODE_GCM)')).toEqual([]);
});

it('flags MD5/SHA-1 unless marked non-security', () => {
expect(ruleIds('a.py', 'digest = hashlib.md5(artifact).hexdigest()')).toContain('py-weak-hash');
expect(ruleIds('a.py', 'digest = hashlib.sha1(artifact).hexdigest()')).toContain('py-weak-hash');
// SHA-256 is fine; and `usedforsecurity=False` is Python's own opt-out.
expect(ruleIds('a.py', 'digest = hashlib.sha256(artifact).hexdigest()')).toEqual([]);
expect(ruleIds('a.py', 'key = hashlib.md5(url, usedforsecurity=False).hexdigest()')).toEqual([]);
});

it('flags a PRNG seeded from the clock, not a fixed reproducible seed', () => {
expect(ruleIds('a.py', 'random.seed(int(time.time()))')).toContain('py-predictable-random-seed');
expect(ruleIds('a.py', 'random.seed(datetime.now().timestamp())')).toContain(
'py-predictable-random-seed',
);
// A constant seed is deliberate reproducibility (tests, simulations).
expect(ruleIds('a.py', 'random.seed(42)')).toEqual([]);
});

it('still catches the same-line credential=random shape via the generic rule', () => {
// The role on the line is what the engine can see without trusting a name.
expect(ruleIds('a.py', 'token = "".join(random.choice(A) for _ in range(32))')).toContain(
'insecure-randomness-for-secret',
);
});

// Documented boundary, not an oversight: when the security role of a
// `random`-drawn value lives only in the enclosing function's name
// (`generate_session_id`, `generate_mfa_code`), it is out of reach. Guard
// windows deliberately exclude definition lines — a name is not evidence,
// the same reason a `def sanitize_…` does not count as sanitisation — so
// there is no line-level signal left to key on.
it('does not flag a bare random draw whose role is only in the function name', () => {
expect(ruleIds('a.py', 'def generate_session_id():\n return "%x" % random.getrandbits(128)')).toEqual(
[],
);
expect(ruleIds('a.py', 'def pick_color():\n return random.choice(PALETTE)')).toEqual([]);
});

it('does not flag the CSPRNG the fixture offers as the fix', () => {
expect(ruleIds('a.py', 'def new_token():\n return secrets.token_urlsafe(32)')).toEqual([]);
expect(ruleIds('a.py', 'def new_id():\n return secrets.token_hex(16)')).toEqual([]);
});
});
56 changes: 56 additions & 0 deletions packages/scan/src/code-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,62 @@ export const CODE_RULES: readonly CodeRule[] = [
pattern:
/(?:token|secret|password|salt|nonce|session|otp|reset|apikey|api_key)[\w]*\s*[:=][^;\n]{0,60}(?:Math\s*\.\s*random\s*\(|\brandom\s*\.\s*(?:random|randint|choice)\s*\(|\brand\s*\()/i,
},

// ── Weak crypto: Python ──────────────────────────────────────────────────
//
// The generic rules above catch the credential case on the matched line.
// These cover what they miss in Python, where the security role of a value is
// set by the enclosing function rather than a same-line assignment — a
// `random`-drawn token that is *returned*, an MD5 used to *verify* an
// artifact — and the broken ciphers, which have no safe use at all.
{
id: 'py-broken-cipher',
title: 'broken cipher or ECB mode',
consequence:
'DES, RC2, RC4 and Blowfish are broken or too small to rely on, and ECB encrypts identical plaintext blocks to identical ciphertext, so structure in the data survives encryption. None of them provides the confidentiality their use implies.',
cwe: 'CWE-327',
severity: 'high',
languages: ['python'],
// PyCryptodome/PyCrypto constructors. The mode matters only for AES, whose
// safe modes (GCM, CTR, CBC) are common — so AES matches solely on ECB,
// while DES/RC4/Blowfish are broken by the algorithm regardless of mode.
pattern:
/\b(?:DES|DES3|ARC2|RC2|ARC4|RC4|Blowfish|XOR)\s*\.\s*new\s*\(|\bAES\s*\.\s*new\s*\([^)\n]*\bMODE_ECB\b/,
inherent: true,
guard: false,
},
{
id: 'py-weak-hash',
title: 'broken hash algorithm',
consequence:
'MD5 and SHA-1 have practical collisions, so a digest used for integrity or a signature can be forged to match a value the code trusts.',
cwe: 'CWE-327',
severity: 'medium',
languages: ['python'],
pattern: /\bhashlib\s*\.\s*(?:md5|sha1)\s*\(/,
// Python 3.9+ marks a non-security digest — a cache key, an ETag — with
// `usedforsecurity=False`, which is exactly the "this MD5 is not a security
// claim" signal, so it exempts the line rather than being flagged.
lineGuard: /usedforsecurity\s*=\s*False/,
},
{
id: 'py-predictable-random-seed',
title: 'PRNG seeded from a predictable value',
consequence:
'Seeding `random` from the clock or the process id makes its whole sequence reproducible, so anything drawn from it afterwards — a token, an id, a shuffle — can be regenerated by guessing the seed.',
cwe: 'CWE-338',
severity: 'high',
languages: ['python'],
// A time- or pid-derived seed, which is the predictable kind. A fixed
// integer seed (`random.seed(42)`) is deliberate reproducibility for tests
// and simulations, so it is left alone. This needs no credential context:
// seeding the global PRNG from the clock is a weakness on its own terms,
// which is why the enclosing function's name — that this engine does not
// read as evidence anyway — is not consulted.
pattern:
/\brandom\s*\.\s*seed\s*\([^)\n]*(?:time\s*\.\s*time|datetime|\.\s*now\s*\(|getpid)/,
inherent: true,
},
{
id: 'redos-nested-quantifier',
title: 'regex with nested unbounded quantifiers',
Expand Down
Loading