praxis-sec 1.1.0 → 1.2.1

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.
Files changed (53) hide show
  1. package/cli/agents/abom-generator.js +1 -1
  2. package/cli/agents/agent-attestation-agent.js +16 -2
  3. package/cli/agents/agent-config-scanner.js +1 -1
  4. package/cli/agents/agent-telemetry-agent.js +8 -1
  5. package/cli/agents/ai-infra-inventory-agent.js +482 -453
  6. package/cli/agents/base-agent.js +61 -1
  7. package/cli/agents/endpoint-agent-abuse-agent.js +2 -2
  8. package/cli/agents/git-history-scanner.js +1 -1
  9. package/cli/agents/html-reporter.js +3 -3
  10. package/cli/agents/index.js +2 -2
  11. package/cli/agents/injection-tester.js +8 -1
  12. package/cli/agents/mcp-security-agent.js +7 -1
  13. package/cli/agents/memory-poisoning-agent.js +1 -1
  14. package/cli/agents/model-file-scanner.js +1 -1
  15. package/cli/agents/orchestrator.js +11 -6
  16. package/cli/agents/prompt-injection-prober.js +228 -224
  17. package/cli/bin/praxis.js +7 -3
  18. package/cli/commands/agent-fix.js +1091 -1245
  19. package/cli/commands/audit.js +1228 -1216
  20. package/cli/commands/baseline.js +1 -1
  21. package/cli/commands/benchmark.js +1 -1
  22. package/cli/commands/ci.js +45 -21
  23. package/cli/commands/deps.js +11 -5
  24. package/cli/commands/env-audit.js +1 -1
  25. package/cli/commands/fix.js +1 -1
  26. package/cli/commands/mcp.js +2 -2
  27. package/cli/commands/openclaw.js +1 -1
  28. package/cli/commands/red-team.js +350 -350
  29. package/cli/commands/remediate.js +1 -1
  30. package/cli/commands/rotate.js +1 -1
  31. package/cli/commands/rules.js +1 -1
  32. package/cli/commands/scan.js +554 -554
  33. package/cli/commands/score.js +1 -1
  34. package/cli/commands/undo.js +22 -77
  35. package/cli/commands/vibe-check.js +1 -1
  36. package/cli/core/fix-plan.js +274 -0
  37. package/cli/core/fs.js +27 -0
  38. package/cli/core/git-clone.js +8 -6
  39. package/cli/core/glob.js +56 -0
  40. package/cli/core/output/html-theme.js +158 -158
  41. package/cli/core/output/sarif.js +2 -2
  42. package/cli/core/web/jobs.js +2 -2
  43. package/cli/core/web/server.js +19 -8
  44. package/cli/data/threatpacks/latest.json +41 -41
  45. package/cli/integrations/github-action.js +136 -0
  46. package/cli/utils/plugin-loader.js +15 -95
  47. package/cli/utils/rule-import.js +227 -227
  48. package/cli/utils/rule-registry.js +425 -425
  49. package/cli/utils/scan-fingerprint.js +1 -1
  50. package/cli/utils/score-history.js +118 -118
  51. package/docs/USAGE.md +16 -9
  52. package/docs/design/WEB-UI.md +4 -5
  53. package/package.json +13 -4
@@ -17,9 +17,63 @@
17
17
 
18
18
  import fs from 'fs';
19
19
  import path from 'path';
20
- import fg from 'fast-glob';
20
+ import fg from '../core/glob.js';
21
21
  import { SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, MAX_FILE_SIZE, MAX_SCAN_FILES, loadGitignorePatterns } from '../utils/patterns.js';
22
22
 
23
+ // =============================================================================
24
+ // RULE-TABLE SUPPRESSION
25
+ // =============================================================================
26
+ //
27
+ // A detection rule table is data, not code, and it necessarily contains the
28
+ // signatures it hunts for: a rule's `description:` spells out the insecure call
29
+ // it is looking for, so that prose matches the rule itself. Scanning our own
30
+ // tables therefore reported every rule describing itself — 138 of 271 findings
31
+ // in a self-scan, over half the report.
32
+ //
33
+ // Keep this comment free of concrete API names: quoting one makes this file a
34
+ // match for the very rule being discussed.
35
+ //
36
+ // Suppression is deliberately two-stage so it cannot mask a real finding:
37
+ // 1. the FILE must be a rule table (two or more matcher entries against rule
38
+ // ids — something application code never declares), and
39
+ // 2. the LINE must be a rule field holding prose or a pattern.
40
+ // A file that merely uses the words "description" or "fix" fails stage 1 and is
41
+ // scanned normally.
42
+
43
+ const RULE_MATCHER_FIELD = /^\s*(?:regex|pattern|detectionRegex)\s*:/;
44
+ const RULE_ID_FIELD = /^\s*(?:rule|id|name)\s*:\s*['"`]/;
45
+ const RULE_PROSE_FIELD =
46
+ /^\s*(?:title|description|fix|note|recommendation|regex|pattern|detectionRegex|severity|cwe|owasp|confidence|id|rule)\s*:/;
47
+
48
+ const MIN_RULE_TABLE_ENTRIES = 2;
49
+
50
+ /**
51
+ * Build the set of line indexes that hold rule-table prose rather than code.
52
+ *
53
+ * Returns `null` when the file is not a rule table, so callers get "scan this
54
+ * file normally" for free. Reuse the returned Set for the whole file: building
55
+ * it is one cheap pass, and callers that scan line by line would otherwise
56
+ * repeat it per line.
57
+ *
58
+ * @param {string[]} lines
59
+ * @returns {Set<number>|null} zero-based indexes of rule-definition lines
60
+ */
61
+ export function ruleTableLineMask(lines) {
62
+ let matchers = 0;
63
+ let ids = 0;
64
+ for (const line of lines) {
65
+ if (RULE_MATCHER_FIELD.test(line)) matchers++;
66
+ else if (RULE_ID_FIELD.test(line)) ids++;
67
+ }
68
+ if (matchers < MIN_RULE_TABLE_ENTRIES || ids < MIN_RULE_TABLE_ENTRIES) return null;
69
+
70
+ const mask = new Set();
71
+ for (let i = 0; i < lines.length; i++) {
72
+ if (RULE_PROSE_FIELD.test(lines[i])) mask.add(i);
73
+ }
74
+ return mask;
75
+ }
76
+
23
77
  // =============================================================================
24
78
  // FINDING FACTORY
25
79
  // =============================================================================
@@ -225,9 +279,15 @@ export class BaseAgent {
225
279
  const lines = content.split('\n');
226
280
  const findings = [];
227
281
 
282
+ // Rule tables state what a vulnerability looks like, so their prose and
283
+ // patterns match the rules looking for them. Skipping those lines is what
284
+ // stops a self-scan from reporting every rule describing itself.
285
+ const ruleTable = ruleTableLineMask(lines);
286
+
228
287
  for (let i = 0; i < lines.length; i++) {
229
288
  const line = lines[i];
230
289
  if (this.isSuppressed(line)) continue;
290
+ if (ruleTable && ruleTable.has(i)) continue;
231
291
 
232
292
  for (const p of patterns) {
233
293
  p.regex.lastIndex = 0;
@@ -27,7 +27,7 @@
27
27
 
28
28
  import fs from 'fs';
29
29
  import path from 'path';
30
- import fg from 'fast-glob';
30
+ import fg from '../core/glob.js';
31
31
  import { BaseAgent, createFinding } from './base-agent.js';
32
32
 
33
33
  // =============================================================================
@@ -247,7 +247,7 @@ export class EndpointAgentAbuseAgent extends BaseAgent {
247
247
  category: this.category,
248
248
  rule: 'EAA_AGENT_CLI_IN_LIFECYCLE',
249
249
  title: `Lifecycle Script "${name}" Invokes an AI Agent CLI`,
250
- description: `An npm lifecycle hook launches a coding-agent CLI. Package installs run without user review, making this a vector for driving a trusted agent from an untrusted parent (EAA-001, observed in the Nx s1ngularity and Trivy OpenVSX incidents).`,
250
+ description: `An npm lifecycle hook launches a coding-agent CLI. Package installs run without user review, making this a vector for driving a trusted agent from an untrusted parent (EAA-001, observed in the Nx s1ngularity and Trivy OpenVSX incidents).`, // praxis-ignore AGENT_RECURSIVE_INVOCATION — description of a finding this scanner emits, not an agent definition
251
251
  matched: cmd.slice(0, 200),
252
252
  confidence: 'medium',
253
253
  cwe: 'CWE-506',
@@ -99,7 +99,7 @@ export class GitHistoryScanner extends BaseAgent {
99
99
  severity: stillExists ? p.severity : this.elevateSeverity(p.severity),
100
100
  category: 'history',
101
101
  rule: 'GIT_HISTORY_SECRET',
102
- title: `Historical Secret: ${p.name}`,
102
+ title: `Historical Secret: ${p.name}`, // praxis-ignore AGENT_LOG_SECRET_KV — title template of a finding this scanner emits, not an agent definition
103
103
  description: stillExists
104
104
  ? `Secret found in current code AND in git history (commit ${currentCommit}).`
105
105
  : `Secret was removed from code but still exists in git history (commit ${currentCommit}, ${currentDate}). Anyone with repo access can retrieve it.`,
@@ -55,7 +55,7 @@ export class HTMLReporter {
55
55
 
56
56
  /**
57
57
  * The provenance line printed in report footers: exactly which tool, runtime and
58
- * vendored data assets produced this document (P-IMP-053). A surprising result should
58
+ * vendored data assets produced this document. A surprising result should
59
59
  * be attributable, not mysterious.
60
60
  */
61
61
  getFingerprintLine(filesScanned = null) {
@@ -880,7 +880,7 @@ function toggleDetail(id) {
880
880
  /**
881
881
  * Score trend over time, from `.praxis/history.json`.
882
882
  *
883
- * This is the part that must not overstate (P-IMP-055). An empty graph reads as
883
+ * This is the part that must not overstate. An empty graph reads as
884
884
  * "flat, no change", which is a different claim from "we have no data", so:
885
885
  * - no prior scans → say the project is at its baseline
886
886
  * - fewer than 3 measurements → say a trend needs more, and show what exists
@@ -985,7 +985,7 @@ function toggleDetail(id) {
985
985
  }
986
986
 
987
987
  /**
988
- * Remediation Ledger — the applied half of the find→fix→verify loop (P-IMP-054).
988
+ * Remediation Ledger — the applied half of the find→fix→verify loop.
989
989
  *
990
990
  * Reads `.praxis/fixes.jsonl` (currently applied changes) and `.praxis/failures.jsonl`
991
991
  * (plans proposed and rejected). Rejections are the more telling half: a rejected fix
@@ -123,11 +123,11 @@ export function buildOrchestrator() {
123
123
  }
124
124
 
125
125
  /**
126
- * Async build — loads built-in agents + any plugins from .praxis/agents/.
126
+ * Async build — loads built-ins and, with explicit trust, .praxis/agents/ plugins.
127
127
  * Preferred over buildOrchestrator() when rootPath is available.
128
128
  *
129
129
  * @param {string} rootPath — project root (for plugin discovery)
130
- * @param {object} options — { verbose, quiet }
130
+ * @param {object} options — { verbose, quiet, trustPlugins }
131
131
  */
132
132
  export async function buildOrchestratorAsync(rootPath, options = {}) {
133
133
  const orchestrator = new OrchestratorClass();
@@ -23,7 +23,14 @@ export const PATTERNS = [
23
23
  {
24
24
  rule: 'SQL_INJECTION_TEMPLATE_LITERAL',
25
25
  title: 'SQL Injection via Template Literal',
26
- regex: /`(?:SELECT|INSERT|UPDATE|DELETE|DROP\s+TABLE|ALTER\s+TABLE|TRUNCATE|CREATE|REPLACE|MERGE)[^`]*\$\{/gi,
26
+ // Each keyword is anchored to the token SQL requires after it. The previous
27
+ // `(?:SELECT|INSERT|...|CREATE|REPLACE|MERGE)[^`]*\$\{` matched the bare
28
+ // keywords, so English words that merely start with one — created, updated,
29
+ // deleted, inserted, replaced — fired a critical on any template literal like
30
+ // `` `created file changed since fix: ${file.path}` ``. Anchoring also lets a
31
+ // word that IS a keyword stand in for SQL only with its real continuation
32
+ // (`MERGE INTO`, `SELECT ... FROM`), not `merge conflict`.
33
+ regex: /`(?:SELECT\s+[\w*`,\s]{1,80}?\b(?:FROM|INTO|SET|VALUES|WHERE)\b|INSERT\s+INTO|UPDATE\s+[\w*`,\s]{1,80}?\bSET\b|DELETE\s+FROM|(?:DROP|ALTER)\s+TABLE|TRUNCATE\s+(?:TABLE\b|(?=[A-Za-z_]*\$\{))|CREATE\s+(?:TABLE|INDEX|UNIQUE|OR\s+REPLACE)|REPLACE\s+INTO|MERGE\s+INTO)[^`]*\$\{/gi,
27
34
  severity: 'critical',
28
35
  cwe: 'CWE-89',
29
36
  owasp: 'A03:2021',
@@ -65,7 +65,13 @@ export const PATTERNS = [
65
65
  {
66
66
  rule: 'MCP_STDIO_NO_SANDBOX',
67
67
  title: 'MCP: stdio Transport Without Sandbox',
68
- regex: /(?:StdioServerTransport|stdio|transport.*stdio)/g,
68
+ // Anchored to actual MCP context: the transport class itself, or a transport/type field
69
+ // whose value is stdio. An earlier version matched the bare substring instead, so every
70
+ // `stdio: 'pipe'` in a child_process call and every comment merely mentioning stdio was
71
+ // reported — 49 of Praxis's own 50 hits for this rule were that mistake, and it was the
72
+ // single largest source of noise in a self-scan. Keep this comment free of the class name
73
+ // or it will match itself.
74
+ regex: /StdioServerTransport|(?:transport|type)["']?\s*[:=]\s*["']?stdio\b/g, // praxis-ignore MCP_STDIO_NO_SANDBOX - this line necessarily names the pattern
69
75
  severity: 'medium',
70
76
  cwe: 'CWE-269',
71
77
  owasp: 'A04:2021',
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  import path from 'path';
33
- import fg from 'fast-glob';
33
+ import fg from '../core/glob.js';
34
34
  import { BaseAgent, createFinding } from './base-agent.js';
35
35
 
36
36
  // =============================================================================
@@ -22,7 +22,7 @@
22
22
 
23
23
  import fs from 'fs';
24
24
  import path from 'path';
25
- import fg from 'fast-glob';
25
+ import fg from '../core/glob.js';
26
26
  import { BaseAgent, createFinding } from './base-agent.js';
27
27
 
28
28
  const MODEL_GLOBS = [
@@ -63,12 +63,17 @@ export class Orchestrator {
63
63
  * Run a single agent with a timeout.
64
64
  */
65
65
  async runAgent(agent, context, timeout) {
66
- return Promise.race([
67
- agent.analyze(context),
68
- new Promise((_, reject) => {
69
- setTimeout(() => reject(new Error(`timed out after ${timeout / 1000}s`)), timeout);
70
- }),
71
- ]);
66
+ let timer;
67
+ try {
68
+ return await Promise.race([
69
+ Promise.resolve().then(() => agent.analyze(context)),
70
+ new Promise((_, reject) => {
71
+ timer = setTimeout(() => reject(new Error(`timed out after ${timeout / 1000}s`)), timeout);
72
+ }),
73
+ ]);
74
+ } finally {
75
+ clearTimeout(timer);
76
+ }
72
77
  }
73
78
 
74
79
  /**