praxis-sec 1.0.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/LICENSE +21 -0
- package/README.md +170 -0
- package/ai-defense/cost-protection.md +292 -0
- package/ai-defense/llm-security-checklist.md +324 -0
- package/ai-defense/prompt-injection-patterns.js +283 -0
- package/ai-defense/system-prompt-armor.md +327 -0
- package/checklists/launch-day.md +168 -0
- package/cli/agents/abom-generator.js +225 -0
- package/cli/agents/agent-attestation-agent.js +318 -0
- package/cli/agents/agent-config-scanner.js +787 -0
- package/cli/agents/agent-telemetry-agent.js +415 -0
- package/cli/agents/agentic-security-agent.js +296 -0
- package/cli/agents/agentic-supply-chain-agent.js +463 -0
- package/cli/agents/ai-infra-inventory-agent.js +449 -0
- package/cli/agents/api-fuzzer.js +345 -0
- package/cli/agents/auth-bypass-agent.js +348 -0
- package/cli/agents/base-agent.js +280 -0
- package/cli/agents/cicd-scanner.js +300 -0
- package/cli/agents/config-auditor.js +757 -0
- package/cli/agents/deep-analyzer.js +776 -0
- package/cli/agents/endpoint-agent-abuse-agent.js +404 -0
- package/cli/agents/exception-handler-agent.js +187 -0
- package/cli/agents/git-history-scanner.js +169 -0
- package/cli/agents/governance-audits.js +138 -0
- package/cli/agents/hermes-security-agent.js +536 -0
- package/cli/agents/html-reporter.js +1125 -0
- package/cli/agents/index.js +147 -0
- package/cli/agents/injection-tester.js +502 -0
- package/cli/agents/legal-risk-agent.js +328 -0
- package/cli/agents/llm-redteam.js +199 -0
- package/cli/agents/managed-agent-scanner.js +333 -0
- package/cli/agents/mcp-security-agent.js +588 -0
- package/cli/agents/memory-poisoning-agent.js +305 -0
- package/cli/agents/mobile-scanner.js +231 -0
- package/cli/agents/model-file-scanner.js +259 -0
- package/cli/agents/orchestrator.js +355 -0
- package/cli/agents/pii-compliance-agent.js +301 -0
- package/cli/agents/policy-engine.js +229 -0
- package/cli/agents/prompt-injection-prober.js +224 -0
- package/cli/agents/rag-security-agent.js +204 -0
- package/cli/agents/recon-agent.js +207 -0
- package/cli/agents/sbom-generator.js +265 -0
- package/cli/agents/scoring-engine.js +273 -0
- package/cli/agents/ssrf-prober.js +130 -0
- package/cli/agents/stateful-watcher.js +238 -0
- package/cli/agents/supabase-rls-agent.js +154 -0
- package/cli/agents/supply-chain-agent.js +857 -0
- package/cli/agents/swarm-orchestrator.js +200 -0
- package/cli/agents/verifier-agent.js +303 -0
- package/cli/agents/vibe-coding-agent.js +250 -0
- package/cli/bin/praxis.js +866 -0
- package/cli/commands/abom.js +73 -0
- package/cli/commands/agent-fix.js +1245 -0
- package/cli/commands/audit.js +1180 -0
- package/cli/commands/autofix.js +383 -0
- package/cli/commands/baseline.js +193 -0
- package/cli/commands/benchmark.js +327 -0
- package/cli/commands/checklist.js +223 -0
- package/cli/commands/ci.js +403 -0
- package/cli/commands/deps.js +516 -0
- package/cli/commands/diff.js +200 -0
- package/cli/commands/doctor.js +195 -0
- package/cli/commands/env-audit.js +349 -0
- package/cli/commands/fix.js +218 -0
- package/cli/commands/guard.js +396 -0
- package/cli/commands/hooks.js +278 -0
- package/cli/commands/init.js +514 -0
- package/cli/commands/legal.js +158 -0
- package/cli/commands/live-advisories.js +241 -0
- package/cli/commands/mcp.js +660 -0
- package/cli/commands/openclaw.js +386 -0
- package/cli/commands/red-team.js +350 -0
- package/cli/commands/redteam.js +78 -0
- package/cli/commands/remediate.js +797 -0
- package/cli/commands/rotate.js +768 -0
- package/cli/commands/rules.js +196 -0
- package/cli/commands/scan-mcp.js +534 -0
- package/cli/commands/scan-skill.js +588 -0
- package/cli/commands/scan-standard.js +251 -0
- package/cli/commands/scan.js +524 -0
- package/cli/commands/score.js +449 -0
- package/cli/commands/shell.js +514 -0
- package/cli/commands/team-report.js +398 -0
- package/cli/commands/undo.js +161 -0
- package/cli/commands/update-intel.js +126 -0
- package/cli/commands/vibe-check.js +276 -0
- package/cli/commands/watch.js +757 -0
- package/cli/commands/web.js +63 -0
- package/cli/core/ast/guardrail-detector.js +141 -0
- package/cli/core/ast/index.js +22 -0
- package/cli/core/ast/parser.js +676 -0
- package/cli/core/ast/scope-tree.js +287 -0
- package/cli/core/ast/taint-tracker.js +158 -0
- package/cli/core/branding.js +37 -0
- package/cli/core/env.js +38 -0
- package/cli/core/errors.js +61 -0
- package/cli/core/fs.js +62 -0
- package/cli/core/output/compliance.js +90 -0
- package/cli/core/output/html-theme.js +158 -0
- package/cli/core/output/index.js +57 -0
- package/cli/core/output/json.js +48 -0
- package/cli/core/output/sarif.js +240 -0
- package/cli/core/version.js +67 -0
- package/cli/core/web/jobs.js +183 -0
- package/cli/core/web/projects.js +146 -0
- package/cli/core/web/server.js +439 -0
- package/cli/data/atlas-knowledge.json +5640 -0
- package/cli/data/eaa-catalog.json +39 -0
- package/cli/data/known-mcps.json +26 -0
- package/cli/data/probes/prompt-injection-corpus.json +271 -0
- package/cli/data/threat-intel.json +85 -0
- package/cli/data/threatpacks/latest.json +41 -0
- package/cli/hooks/patterns.js +313 -0
- package/cli/hooks/post-tool-use.js +140 -0
- package/cli/hooks/pre-tool-use.js +186 -0
- package/cli/index.js +90 -0
- package/cli/providers/llm-provider.js +766 -0
- package/cli/utils/autofix-rules.js +74 -0
- package/cli/utils/cache-manager.js +310 -0
- package/cli/utils/compliance-map.js +191 -0
- package/cli/utils/entropy.js +132 -0
- package/cli/utils/fix-ledger.js +127 -0
- package/cli/utils/hermes-tool-registry.js +252 -0
- package/cli/utils/intel/cache.js +61 -0
- package/cli/utils/intel/http.js +88 -0
- package/cli/utils/intel/index.js +235 -0
- package/cli/utils/intel/merge.js +229 -0
- package/cli/utils/intel/sources/epss.js +54 -0
- package/cli/utils/intel/sources/ghsa.js +81 -0
- package/cli/utils/intel/sources/gitguardian.js +40 -0
- package/cli/utils/intel/sources/gitleaks.js +101 -0
- package/cli/utils/intel/sources/kev.js +38 -0
- package/cli/utils/intel/sources/nvd.js +84 -0
- package/cli/utils/intel/sources/osv.js +132 -0
- package/cli/utils/intel/sources/phylum.js +44 -0
- package/cli/utils/intel/sources/snyk.js +46 -0
- package/cli/utils/intel/sources/socket.js +69 -0
- package/cli/utils/intel/sources/sonatype.js +84 -0
- package/cli/utils/intel/sources/threatpack.js +69 -0
- package/cli/utils/mcp-trust.js +60 -0
- package/cli/utils/output.js +251 -0
- package/cli/utils/patterns.js +1130 -0
- package/cli/utils/pdf-generator.js +94 -0
- package/cli/utils/plugin-loader.js +364 -0
- package/cli/utils/rule-import.js +228 -0
- package/cli/utils/rule-registry.js +426 -0
- package/cli/utils/scan-fingerprint.js +109 -0
- package/cli/utils/scan-playbook.js +312 -0
- package/cli/utils/score-history.js +119 -0
- package/cli/utils/secrets-verifier.js +247 -0
- package/cli/utils/security-memory.js +296 -0
- package/cli/utils/standards/atlas-knowledge.js +87 -0
- package/cli/utils/standards/index.js +127 -0
- package/cli/utils/standards/sources/avid.js +45 -0
- package/cli/utils/standards/sources/eu-ai-act.js +89 -0
- package/cli/utils/standards/sources/google-saif.js +39 -0
- package/cli/utils/standards/sources/iso-42001.js +94 -0
- package/cli/utils/standards/sources/mitre-atlas.js +54 -0
- package/cli/utils/standards/sources/nist-ai-600-1.js +45 -0
- package/cli/utils/standards/sources/owasp-llm.js +45 -0
- package/cli/utils/standards/sources/owasp-ml.js +45 -0
- package/cli/utils/threat-intel.js +265 -0
- package/configs/firebase/firestore-rules.txt +215 -0
- package/configs/firebase/security-checklist.md +236 -0
- package/configs/firebase/storage-rules.txt +206 -0
- package/configs/gitignore-template +258 -0
- package/configs/nextjs-security-headers.js +220 -0
- package/configs/praxisignore-template +50 -0
- package/configs/supabase/secure-client.ts +225 -0
- package/configs/supabase/security-checklist.md +278 -0
- package/docs/THIRD_PARTY_NOTICES.md +26 -0
- package/docs/THREAT_INTEL.md +292 -0
- package/docs/USAGE.md +1205 -0
- package/docs/design/WEB-UI.md +82 -0
- package/package.json +71 -0
- package/scripts/check-determinism.mjs +119 -0
- package/snippets/README.md +122 -0
- package/snippets/api-security/api-security-checklist.md +412 -0
- package/snippets/api-security/cors-config.ts +322 -0
- package/snippets/api-security/input-validation.ts +430 -0
- package/snippets/auth/jwt-checklist.md +322 -0
- package/snippets/rate-limiting/nextjs-middleware.ts +211 -0
- package/snippets/rate-limiting/upstash-ratelimit.ts +229 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable rule registry — export Praxis rules in a form other tools can read.
|
|
3
|
+
* ============================================================================
|
|
4
|
+
*
|
|
5
|
+
* Why this exists (P-IMP-059). Measured on 2026-10-02:
|
|
6
|
+
*
|
|
7
|
+
* 497 pattern rules total — 372 in agent rule tables + 125 in patterns.js
|
|
8
|
+
* 100% of them are plain {rule, title, regex, severity, cwe, owasp, …} records
|
|
9
|
+
* regex construct usage: lookbehind 4, negative lookahead 50,
|
|
10
|
+
* named groups / backrefs / unicode properties: 0
|
|
11
|
+
*
|
|
12
|
+
* Every construct in use is supported by PCRE2, which is what Semgrep's
|
|
13
|
+
* `pattern-regex` compiles to — so the *format* is mechanically portable even though
|
|
14
|
+
* three subsystems are not:
|
|
15
|
+
*
|
|
16
|
+
* - the AST / taint agents (2 agents) — semantic analysis, not a pattern
|
|
17
|
+
* - the prompt-injection prober — rules live in a versioned data corpus
|
|
18
|
+
* - entropy-checked secrets (11 rules) — a runtime heuristic, not a static match
|
|
19
|
+
*
|
|
20
|
+
* Those are declared in the manifest as Praxis-only. The point is a *precise* claim:
|
|
21
|
+
* "portable patterns plus proprietary depth" is checkable; "closed ecosystem" is just
|
|
22
|
+
* a weakness. Nothing here is inferred or faked — a rule that cannot be exported is
|
|
23
|
+
* reported as such rather than approximated.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from 'fs';
|
|
27
|
+
import path from 'path';
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
29
|
+
|
|
30
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
// This module lives in cli/utils/; the agent rule tables live beside it in cli/agents/.
|
|
32
|
+
const AGENT_DIR = path.resolve(__dirname, '..', 'agents');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Semgrep severity names, mapped from Praxis tiers.
|
|
36
|
+
* NOTE: `critical` and `high` intentionally share Semgrep's top band because Semgrep
|
|
37
|
+
* exposes no "critical"; the numeric `security-severity` metadata carries the finer
|
|
38
|
+
* distinction, which is the same reason the SARIF output does.
|
|
39
|
+
*/
|
|
40
|
+
export const SEMGREP_SEVERITY = {
|
|
41
|
+
critical: 'ERROR',
|
|
42
|
+
high: 'ERROR',
|
|
43
|
+
medium: 'WARNING',
|
|
44
|
+
low: 'INFO',
|
|
45
|
+
info: 'INFO',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** JS-only regex flags and how to express them in PCRE2 / drop them. */
|
|
49
|
+
function translateFlags(flags = '') {
|
|
50
|
+
let prefix = '';
|
|
51
|
+
const notes = [];
|
|
52
|
+
if (flags.includes('i')) prefix += '(?i)';
|
|
53
|
+
if (flags.includes('m')) { prefix += '(?m)'; notes.push('m flag -> (?m)'); }
|
|
54
|
+
if (flags.includes('s')) { prefix += '(?s)'; notes.push('s flag -> (?s)'); }
|
|
55
|
+
if (flags.includes('g')) notes.push('g flag dropped (Semgrep matches all occurrences)');
|
|
56
|
+
if (flags.includes('y')) notes.push('y (sticky) flag has no PCRE2 equivalent and was dropped');
|
|
57
|
+
if (flags.includes('u')) notes.push('u (unicode) flag is JS-specific; emitted without it');
|
|
58
|
+
return { prefix, notes };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Converts a JS RegExp literal source into a PCRE2-compatible pattern string.
|
|
63
|
+
* Returns `{ pattern, notes }`.
|
|
64
|
+
*/
|
|
65
|
+
export function toPortablePattern(regexSource, flags = '') {
|
|
66
|
+
const { prefix, notes } = translateFlags(flags);
|
|
67
|
+
let body = regexSource;
|
|
68
|
+
|
|
69
|
+
// JS \\u{XXXX} (requires the `u` flag) is spelled \\x{XXXX} in PCRE2.
|
|
70
|
+
if (/\\u\{[0-9a-fA-F]+\}/.test(body)) {
|
|
71
|
+
body = body.replace(/\\u\{([0-9a-fA-F]+)\}/g, '\\x{$1}');
|
|
72
|
+
notes.push('unicode escape \\u{...} rewritten to PCRE2 \\x{...}');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { pattern: prefix + body, notes };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Verifies a portable pattern still compiles. Used as a pre-write gate so the export
|
|
80
|
+
* cannot ship YAML that another tool would choke on.
|
|
81
|
+
*/
|
|
82
|
+
export function validatePattern(pattern) {
|
|
83
|
+
// Deliberately NOT `new RegExp(pattern)` as-is: our output is PCRE2, and JavaScript
|
|
84
|
+
// rejects PCRE2 inline flag groups with "Invalid group" - which failed every
|
|
85
|
+
// case-insensitive rule for entirely the wrong reason. Strip the leading inline-flag
|
|
86
|
+
// groups (always valid PCRE2) and compile the body with JS, which validates the part
|
|
87
|
+
// that can genuinely be malformed.
|
|
88
|
+
const original = String(pattern);
|
|
89
|
+
const body = original
|
|
90
|
+
.replace(/^(?:\(\?[imsx]+\))+/, '')
|
|
91
|
+
.replace(/\\x\{([0-9a-fA-F]+)\}/g, '\\u{$1}');
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// eslint-disable-next-line no-new
|
|
95
|
+
new RegExp(body, 'u'); // praxis-ignore REDOS — validated against adversarial input (15KB of [[: , (? , \A repetition): 0ms. No nested quantifier.
|
|
96
|
+
return { status: 'valid' };
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// Some valid PCRE2 has no JavaScript equivalent at all. Those are not broken
|
|
99
|
+
// rules; they are rules this environment cannot machine-check, because there is no
|
|
100
|
+
// PCRE2 engine here. Report that honestly rather than calling them invalid - a
|
|
101
|
+
// false "invalid" would make a user delete a working rule.
|
|
102
|
+
if (PCRE2_ONLY.test(original)) {
|
|
103
|
+
return { status: 'unverifiable', error: err.message };
|
|
104
|
+
}
|
|
105
|
+
return { status: 'invalid', error: err.message };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Constructs PCRE2 accepts that JavaScript's RegExp does not. A pattern containing one
|
|
111
|
+
* of these cannot be machine-checked in this environment; a pattern without them that
|
|
112
|
+
* fails to compile really is malformed.
|
|
113
|
+
*/
|
|
114
|
+
const PCRE2_ONLY =
|
|
115
|
+
/\(\?>|\(\?R|\(\?[1-9]\d*\)|\\K|\\A|\\z|\\Z|\\G|\(\?x\)|\[\[:(?:alpha|alnum|ascii|blank|cntrl|digit|graph|lower|print|punct|space|upper|word|xdigit):\]\]/;
|
|
116
|
+
|
|
117
|
+
/** YAML single-quoted scalar: only `'` needs escaping; backslashes stay literal. */
|
|
118
|
+
export function yamlSingleQuoted(value) {
|
|
119
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Normalizes a rule label into a stable Semgrep rule id.
|
|
124
|
+
*
|
|
125
|
+
* Semgrep ids are what people write in `# nosemgrep:` suppressions and what baselining
|
|
126
|
+
* keys on, so they must be identifiers — not human labels. `patterns.js` carries
|
|
127
|
+
* `name: "AWS Access Key ID"` with no id field at all, so the label is slugified and
|
|
128
|
+
* the original is kept as the title.
|
|
129
|
+
*/
|
|
130
|
+
export function toRuleId(raw) {
|
|
131
|
+
const label = raw.rule || raw.id || raw.name || raw.patternName || null;
|
|
132
|
+
if (!label) return null;
|
|
133
|
+
const slug = String(label)
|
|
134
|
+
.trim()
|
|
135
|
+
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
136
|
+
.replace(/^_+|_+$/g, '')
|
|
137
|
+
.toUpperCase();
|
|
138
|
+
return slug || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Normalizes one raw rule record into the portable shape.
|
|
143
|
+
* `origin` explains where it came from, for the manifest.
|
|
144
|
+
*/
|
|
145
|
+
function normalizeRule(raw, origin) {
|
|
146
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
147
|
+
|
|
148
|
+
// Rule tables are not uniform: agent tables use `regex`, patterns.js uses `pattern`,
|
|
149
|
+
// and some entries carry a RegExp literal while others carry a plain string. Read
|
|
150
|
+
// either field and either representation, or a whole rule family is silently dropped.
|
|
151
|
+
let regex = raw.regex ?? raw.pattern;
|
|
152
|
+
let flags = '';
|
|
153
|
+
if (regex instanceof RegExp) {
|
|
154
|
+
flags = regex.flags || '';
|
|
155
|
+
} else if (typeof regex === 'string') {
|
|
156
|
+
flags = 'g';
|
|
157
|
+
} else {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
if (typeof regex.source !== 'string') return null;
|
|
161
|
+
regex = { source: regex.source, flags };
|
|
162
|
+
|
|
163
|
+
const { pattern, notes } = toPortablePattern(regex.source, flags);
|
|
164
|
+
const validation = validatePattern(pattern);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
id: toRuleId(raw),
|
|
168
|
+
title: raw.title || raw.name || raw.patternName || null,
|
|
169
|
+
severity: String(raw.severity || 'medium').toLowerCase(),
|
|
170
|
+
cwe: raw.cwe || null,
|
|
171
|
+
owasp: raw.owasp || null,
|
|
172
|
+
description: raw.description || null,
|
|
173
|
+
fix: raw.fix || null,
|
|
174
|
+
requiresEntropyCheck: raw.requiresEntropyCheck === true,
|
|
175
|
+
origin,
|
|
176
|
+
jsSource: regex.source,
|
|
177
|
+
jsFlags: flags,
|
|
178
|
+
portablePattern: pattern,
|
|
179
|
+
notes,
|
|
180
|
+
// Tri-state: 'valid' (compiled), 'unverifiable' (valid PCRE2 with no JS equivalent,
|
|
181
|
+
// so not machine-checkable here), 'invalid' (genuinely malformed).
|
|
182
|
+
valid: validation.status === 'valid',
|
|
183
|
+
verifiable: validation.status !== 'invalid',
|
|
184
|
+
validationStatus: validation.status,
|
|
185
|
+
validationError: validation.status === 'valid' ? null : validation.error,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Rule tables exported by agents and by patterns.js, discovered dynamically. */
|
|
190
|
+
const AGENT_MODULES = [
|
|
191
|
+
'agent-attestation-agent.js', 'agent-config-scanner.js', 'agent-telemetry-agent.js',
|
|
192
|
+
'agentic-security-agent.js', 'agentic-supply-chain-agent.js', 'api-fuzzer.js',
|
|
193
|
+
'auth-bypass-agent.js', 'cicd-scanner.js', 'config-auditor.js', 'exception-handler-agent.js',
|
|
194
|
+
'governance-audits.js', 'hermes-security-agent.js', 'injection-tester.js', 'llm-redteam.js',
|
|
195
|
+
'managed-agent-scanner.js', 'mcp-security-agent.js', 'memory-poisoning-agent.js',
|
|
196
|
+
'mobile-scanner.js', 'pii-compliance-agent.js', 'rag-security-agent.js',
|
|
197
|
+
'ssrf-prober.js', 'supabase-rls-agent.js', 'supply-chain-agent.js', 'verifier-agent.js', // praxis-ignore AGENT_CHAIN_NO_ISOLATION — static list of own agent module filenames, not an agent chaining calls
|
|
198
|
+
'vibe-coding-agent.js',
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
const EXPORT_NAMES = [
|
|
202
|
+
'PATTERNS', 'SECRET_PATTERNS', 'BROAD_SCOPE_PATTERNS', 'DOCKERFILE_PATTERNS',
|
|
203
|
+
'OVERSIGHT_PATTERNS', 'HERMES_FILE_PATTERNS', 'STATIC_PATTERNS', 'INJECTION_PATTERNS',
|
|
204
|
+
'CLIENT_PATTERNS', 'SUSPICIOUS_NAME_PATTERNS', 'SANITIZATION_PATTERNS',
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Collects every exportable rule. Best-effort: a module that fails to import is
|
|
209
|
+
* reported rather than silently omitted.
|
|
210
|
+
*
|
|
211
|
+
* @returns {Promise<{rules: object[], errors: {module: string, error: string}[], skipped: object[]}>}
|
|
212
|
+
*/
|
|
213
|
+
export async function collectPortableRules() {
|
|
214
|
+
const rules = [];
|
|
215
|
+
const errors = [];
|
|
216
|
+
const skipped = [];
|
|
217
|
+
|
|
218
|
+
// 1. Shared pattern tables.
|
|
219
|
+
try {
|
|
220
|
+
const patterns = await import('../utils/patterns.js');
|
|
221
|
+
for (const [name, table] of [
|
|
222
|
+
['SECRET_PATTERNS', patterns.SECRET_PATTERNS],
|
|
223
|
+
['SECURITY_PATTERNS', patterns.SECURITY_PATTERNS],
|
|
224
|
+
['TEST_FILE_PATTERNS', patterns.TEST_FILE_PATTERNS],
|
|
225
|
+
]) {
|
|
226
|
+
if (!Array.isArray(table)) continue;
|
|
227
|
+
for (const raw of table) {
|
|
228
|
+
const r = normalizeRule(raw, `patterns.js:${name}`);
|
|
229
|
+
if (r) rules.push(r);
|
|
230
|
+
else skipped.push({ origin: `patterns.js:${name}`, reason: 'no regex field' });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch (err) {
|
|
234
|
+
errors.push({ module: 'patterns.js', error: err.message });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 2. Agent rule tables.
|
|
238
|
+
for (const mod of AGENT_MODULES) {
|
|
239
|
+
let ns;
|
|
240
|
+
try {
|
|
241
|
+
// ESM import() needs a file:// URL; a raw Windows path is rejected outright.
|
|
242
|
+
ns = await import(pathToFileURL(path.join(AGENT_DIR, mod)).href);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
errors.push({ module: mod, error: err.message });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
for (const name of EXPORT_NAMES) {
|
|
248
|
+
const table = ns[name];
|
|
249
|
+
if (!Array.isArray(table)) continue;
|
|
250
|
+
for (const raw of table) {
|
|
251
|
+
const r = normalizeRule(raw, `${mod}:${name}`);
|
|
252
|
+
if (r) rules.push(r);
|
|
253
|
+
else skipped.push({ origin: `${mod}:${name}`, reason: 'no regex field' });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { rules, errors, skipped };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Resolves duplicate rule ids deterministically and records the collisions, so an
|
|
263
|
+
* exported bundle never silently shadows a rule.
|
|
264
|
+
*/
|
|
265
|
+
export function dedupeRules(rules) {
|
|
266
|
+
const byId = new Map();
|
|
267
|
+
const collisions = [];
|
|
268
|
+
for (const r of rules) {
|
|
269
|
+
if (!r.id) continue;
|
|
270
|
+
if (!byId.has(r.id)) {
|
|
271
|
+
byId.set(r.id, r);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
collisions.push({ id: r.id, origins: [byId.get(r.id).origin, r.origin] });
|
|
275
|
+
// Suffix with a short origin hash so both remain addressable.
|
|
276
|
+
r.id = `${r.id}__${Buffer.from(r.origin).toString('base64url').slice(0, 6)}`;
|
|
277
|
+
}
|
|
278
|
+
return { rules, collisions };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Renders Semgrep-compatible YAML.
|
|
283
|
+
*
|
|
284
|
+
* Uses `pattern-regex` (PCRE2) rather than a code `pattern`, because that is the
|
|
285
|
+
* faithful mapping for rules that were regexes to begin with. Single-quoted YAML
|
|
286
|
+
* scalars keep backslashes literal, which is exactly what a regex needs.
|
|
287
|
+
*/
|
|
288
|
+
export function toSemgrepYAML(rules, { toolVersion = null } = {}) {
|
|
289
|
+
const lines = [];
|
|
290
|
+
lines.push('# Generated by Praxis — portable pattern rules.');
|
|
291
|
+
lines.push('# https://github.com/Ganron007/Praxis');
|
|
292
|
+
lines.push('#');
|
|
293
|
+
lines.push('# These are the PATTERN rules only. Praxis additionally ships layers with no');
|
|
294
|
+
lines.push('# Semgrep equivalent, which are listed in praxis-rules.manifest.json:');
|
|
295
|
+
lines.push('# - AST / taint dataflow agents (semantic, not pattern-based)');
|
|
296
|
+
lines.push('# - the prompt-injection probe corpus (versioned data, own compiler)');
|
|
297
|
+
lines.push('# - entropy-checked secret patterns (runtime heuristic)');
|
|
298
|
+
lines.push('#');
|
|
299
|
+
lines.push('# Semgrep-compatible. Use: semgrep --config praxis-rules.yaml <target>');
|
|
300
|
+
lines.push('');
|
|
301
|
+
if (toolVersion) lines.push(`# praxis version: ${toolVersion}`);
|
|
302
|
+
if (toolVersion) lines.push('');
|
|
303
|
+
|
|
304
|
+
for (const r of rules) {
|
|
305
|
+
lines.push('- id: ' + r.id);
|
|
306
|
+
lines.push(' patterns:');
|
|
307
|
+
lines.push(' - pattern-regex: ' + yamlSingleQuoted(r.portablePattern));
|
|
308
|
+
lines.push(' message: ' + yamlSingleQuoted(r.description || r.title || r.id));
|
|
309
|
+
lines.push(' severity: ' + (SEMGREP_SEVERITY[r.severity] || 'WARNING'));
|
|
310
|
+
lines.push(' languages: [generic]');
|
|
311
|
+
const meta = [];
|
|
312
|
+
if (r.cwe) meta.push(`cwe: ${yamlSingleQuoted(String(r.cwe))}`);
|
|
313
|
+
if (r.owasp) meta.push(`owasp: ${yamlSingleQuoted(String(r.owasp))}`);
|
|
314
|
+
if (r.origin) meta.push(`praxis-origin: ${yamlSingleQuoted(r.origin)}`);
|
|
315
|
+
meta.push(`security-severity: ${yamlSingleQuoted(securitySeverity(r.severity))}`);
|
|
316
|
+
lines.push(' metadata:');
|
|
317
|
+
for (const m of meta) lines.push(' ' + m);
|
|
318
|
+
lines.push('');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return lines.join('\n');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Same numeric scale the SARIF output uses, so the two never disagree. */
|
|
325
|
+
export function securitySeverity(severity) {
|
|
326
|
+
return { critical: '9.5', high: '7.5', medium: '5.0', low: '2.5', info: '0.0' }[severity] ?? '5.0';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The manifest: what is portable, what is not, and why. This is the honest half of
|
|
331
|
+
* the feature — a bundle without it would overstate coverage.
|
|
332
|
+
*/
|
|
333
|
+
export function buildManifest({ rules, collisions, errors, skipped, toolVersion = null }) {
|
|
334
|
+
const portable = rules.filter(r => r.valid);
|
|
335
|
+
const unverifiable = rules.filter(r => r.validationStatus === 'unverifiable');
|
|
336
|
+
const invalid = rules.filter(r => r.validationStatus === 'invalid');
|
|
337
|
+
const entropyChecked = portable.filter(r => r.requiresEntropyCheck);
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
generator: 'praxis rules export',
|
|
341
|
+
praxisVersion: toolVersion,
|
|
342
|
+
format: 'semgrep-compatible pattern-regex (PCRE2)',
|
|
343
|
+
generated: new Date().toISOString(),
|
|
344
|
+
counts: {
|
|
345
|
+
total: rules.length,
|
|
346
|
+
portable: portable.length,
|
|
347
|
+
// Valid PCRE2 that this environment has no engine to machine-check (unicode
|
|
348
|
+
// escapes with no JS equivalent). Exported, but flagged rather than claimed verified.
|
|
349
|
+
unverifiable: unverifiable.length,
|
|
350
|
+
invalid: invalid.length,
|
|
351
|
+
entropyChecked: entropyChecked.length,
|
|
352
|
+
collisions: collisions.length,
|
|
353
|
+
skipped: skipped.length,
|
|
354
|
+
moduleErrors: errors.length,
|
|
355
|
+
},
|
|
356
|
+
portableRuleIds: portable.map(r => r.id),
|
|
357
|
+
// Declared, not inferred: these layers have no Semgrep representation.
|
|
358
|
+
praxisOnlyLayers: [
|
|
359
|
+
{
|
|
360
|
+
layer: 'AST / taint dataflow',
|
|
361
|
+
reason: 'Semantic intra-file taint tracking. A regex cannot express "user input reaches this sink".',
|
|
362
|
+
agents: ['verifier-agent.js', 'core/ast/*'],
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
layer: 'Prompt-injection probe corpus',
|
|
366
|
+
reason: 'Rules live in versioned data (cli/data/probes) and are compiled with a ReDoS guard, not hardcoded regexes.',
|
|
367
|
+
data: 'cli/data/probes/prompt-injection-corpus.json, cli/data/threatpacks/latest.json',
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
layer: 'Entropy-checked secret patterns',
|
|
371
|
+
reason: 'A runtime Shannon-entropy heuristic over the matched string, not a static pattern.',
|
|
372
|
+
count: entropyChecked.length,
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
layer: 'LLM deep analysis (--deep)',
|
|
376
|
+
reason: 'Runtime exploitability verdicts; not a static rule.',
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
collisions,
|
|
380
|
+
invalidPatterns: invalid.map(r => ({ id: r.id, pattern: r.portablePattern, error: r.validationError })),
|
|
381
|
+
unverifiablePatterns: unverifiable.map(r => ({
|
|
382
|
+
id: r.id,
|
|
383
|
+
pattern: r.portablePattern,
|
|
384
|
+
reason: 'valid PCRE2, but no JavaScript equivalent exists to machine-check it here',
|
|
385
|
+
jsError: r.validationError,
|
|
386
|
+
})),
|
|
387
|
+
skipped,
|
|
388
|
+
moduleErrors: errors,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Writes the bundle: Semgrep YAML (interop) + JSON (canonical round-trip) + manifest. */
|
|
393
|
+
export function writeBundle({ rules, collisions, errors, skipped, outDir, toolVersion = null }) {
|
|
394
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
395
|
+
const yamlPath = path.join(outDir, 'praxis-rules.yaml');
|
|
396
|
+
const jsonPath = path.join(outDir, 'praxis-rules.json');
|
|
397
|
+
const manifestPath = path.join(outDir, 'praxis-rules.manifest.json');
|
|
398
|
+
|
|
399
|
+
fs.writeFileSync(yamlPath, toSemgrepYAML(rules, { toolVersion }), 'utf8');
|
|
400
|
+
fs.writeFileSync(manifestPath, JSON.stringify(buildManifest({ rules, collisions, errors, skipped, toolVersion }), null, 2), 'utf8');
|
|
401
|
+
|
|
402
|
+
// JSON is the canonical format `praxis rules import` consumes. Praxis has no YAML
|
|
403
|
+
// runtime dependency, and a bundle that round-trips must not depend on one.
|
|
404
|
+
fs.writeFileSync(jsonPath, JSON.stringify({
|
|
405
|
+
generator: 'praxis rules export',
|
|
406
|
+
praxisVersion: toolVersion,
|
|
407
|
+
format: 'praxis-portable-rules/1',
|
|
408
|
+
note: 'Canonical import format. The sibling .yaml is Semgrep interop and is not importable.',
|
|
409
|
+
rules: rules.map(r => ({
|
|
410
|
+
id: r.id,
|
|
411
|
+
title: r.title,
|
|
412
|
+
severity: r.severity,
|
|
413
|
+
description: r.description,
|
|
414
|
+
fix: r.fix,
|
|
415
|
+
cwe: r.cwe,
|
|
416
|
+
owasp: r.owasp,
|
|
417
|
+
pattern: r.portablePattern,
|
|
418
|
+
flags: r.jsFlags,
|
|
419
|
+
origin: r.origin,
|
|
420
|
+
validationStatus: r.validationStatus,
|
|
421
|
+
requiresEntropyCheck: r.requiresEntropyCheck,
|
|
422
|
+
})),
|
|
423
|
+
}, null, 2), 'utf8');
|
|
424
|
+
|
|
425
|
+
return { yamlPath, jsonPath, manifestPath };
|
|
426
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan fingerprint — identify exactly what produced a set of findings.
|
|
3
|
+
* ============================================================================
|
|
4
|
+
*
|
|
5
|
+
* Why this exists (P-IMP-053): during self-dogfooding a scan reported 325 findings
|
|
6
|
+
* where four subsequent scans of the identical tree reported 308. The extra 17 were
|
|
7
|
+
* `PROBE_*` / `VIBE_*` rules in files nobody had touched. Data drift, test fixtures
|
|
8
|
+
* and the scan cache were each ruled out, so the cause stayed unknown.
|
|
9
|
+
*
|
|
10
|
+
* The response to "I cannot explain this number" is not a suppression — it is to make
|
|
11
|
+
* the number attributable. Every scan now carries the tool version, the runtime, and
|
|
12
|
+
* the version of every vendored data asset that can change detection behaviour, so a
|
|
13
|
+
* surprising result can be traced to a specific input instead of guessed at.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is best-effort and never throws: a missing or malformed data file
|
|
16
|
+
* degrades to `null`, because a fingerprint must not be able to fail a scan.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
import { toolVersion } from '../core/version.js';
|
|
23
|
+
|
|
24
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const CLI_DIR = path.resolve(__dirname, '..');
|
|
26
|
+
|
|
27
|
+
/** Praxis version comes from the single source in `cli/core/version.js`. */
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reads a vendored data asset, returning its version and item count.
|
|
31
|
+
* Never throws — an unreadable asset degrades to nulls rather than failing a scan.
|
|
32
|
+
*/
|
|
33
|
+
function readAssetVersion(relPath, countKey) {
|
|
34
|
+
try {
|
|
35
|
+
const data = JSON.parse(fs.readFileSync(path.join(CLI_DIR, 'data', relPath), 'utf8'));
|
|
36
|
+
const items = countKey && Array.isArray(data[countKey]) ? data[countKey].length : null;
|
|
37
|
+
return { version: data.version ?? null, items };
|
|
38
|
+
} catch {
|
|
39
|
+
return { version: null, items: null };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Builds the fingerprint for a scan run.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} [opts]
|
|
47
|
+
* @param {number} [opts.filesScanned] Number of files the scan walked.
|
|
48
|
+
* @returns {{tool: string|null, node: string, data: object, filesScanned: number|null}}
|
|
49
|
+
*/
|
|
50
|
+
export function buildScanFingerprint({ filesScanned = null } = {}) {
|
|
51
|
+
return {
|
|
52
|
+
tool: toolVersion(),
|
|
53
|
+
node: process.version,
|
|
54
|
+
data: {
|
|
55
|
+
probeCorpus: readAssetVersion(path.join('probes', 'prompt-injection-corpus.json'), 'probes'),
|
|
56
|
+
threatPack: readAssetVersion(path.join('threatpacks', 'latest.json'), 'probes'),
|
|
57
|
+
eaaCatalog: readAssetVersion('eaa-catalog.json', null),
|
|
58
|
+
},
|
|
59
|
+
filesScanned: typeof filesScanned === 'number' ? filesScanned : null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One-line, human-readable rendering of a fingerprint, for report footers and CI logs.
|
|
65
|
+
* Unknown values render as `?` so a missing asset is visible rather than silent.
|
|
66
|
+
*/
|
|
67
|
+
export function fingerprintLine(fp) {
|
|
68
|
+
if (!fp) return '';
|
|
69
|
+
const d = fp.data || {};
|
|
70
|
+
const probes = d.probeCorpus || {};
|
|
71
|
+
const pack = d.threatPack || {};
|
|
72
|
+
const eaa = d.eaaCatalog || {};
|
|
73
|
+
return [
|
|
74
|
+
`praxis ${fp.tool ?? '?'}`,
|
|
75
|
+
`node ${fp.node ?? '?'}`,
|
|
76
|
+
`probes v${probes.version ?? '?'}(${probes.items ?? '?'})`,
|
|
77
|
+
`threatpack v${pack.version ?? '?'}(${pack.items ?? '?'})`,
|
|
78
|
+
`eaa v${eaa.version ?? '?'}`,
|
|
79
|
+
`files ${fp.filesScanned ?? '?'}`,
|
|
80
|
+
].join(' · ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stable identity for a finding, used to compare two runs of the same target.
|
|
85
|
+
*
|
|
86
|
+
* Deliberately excludes severity, line numbers and messages so that a change in
|
|
87
|
+
* *scoring* does not read as a change in *detection*.
|
|
88
|
+
*
|
|
89
|
+
* Uses a double colon as the separator: finding.file can be a Windows path
|
|
90
|
+
* (`C:/src/a.js`), which already contains a colon, so a single colon would make the
|
|
91
|
+
* identity ambiguous to split.
|
|
92
|
+
*/
|
|
93
|
+
export function findingIdentity(finding) {
|
|
94
|
+
return `${finding?.file ?? '?'}::${finding?.rule ?? '?'}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Compares two scans of the same target and returns the set differences.
|
|
99
|
+
* @returns {{added: string[], removed: string[], common: number}}
|
|
100
|
+
*/
|
|
101
|
+
export function diffFindings(before = [], after = []) {
|
|
102
|
+
const a = new Set((before || []).map(findingIdentity));
|
|
103
|
+
const b = new Set((after || []).map(findingIdentity));
|
|
104
|
+
return {
|
|
105
|
+
added: [...b].filter(id => !a.has(id)).sort(),
|
|
106
|
+
removed: [...a].filter(id => !b.has(id)).sort(),
|
|
107
|
+
common: [...b].filter(id => a.has(id)).length,
|
|
108
|
+
};
|
|
109
|
+
}
|