docguard-cli 0.25.1 → 0.27.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.
@@ -8,6 +8,18 @@
8
8
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
9
9
  import { resolve, join, extname } from 'node:path';
10
10
  import { shouldIgnore, relPosix } from '../shared-ignore.mjs';
11
+ import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
12
+
13
+ // Each secret pattern maps to a stable finding code (see cli/findings.mjs CODES)
14
+ // so it is `explain`-able and inline-suppressible (`// docguard:ignore SEC00x`).
15
+ const LABEL_TO_CODE = {
16
+ 'hardcoded password': 'SEC001',
17
+ 'hardcoded API key': 'SEC002',
18
+ 'hardcoded secret key': 'SEC003',
19
+ 'hardcoded access token': 'SEC004',
20
+ 'AWS Access Key ID': 'SEC005',
21
+ 'API secret key (Stripe/OpenAI pattern)': 'SEC006',
22
+ };
11
23
 
12
24
  const CODE_EXTENSIONS = new Set([
13
25
  '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
@@ -54,11 +66,46 @@ function isSafePlaceholder(line, matchStr) {
54
66
  return SAFE_PATTERNS.some(p => p.test(line));
55
67
  }
56
68
 
57
- export function validateSecurity(projectDir, config) {
58
- const results = { name: 'security', errors: [], warnings: [], passed: 0, total: 0 };
69
+ /**
70
+ * v0.27 (field report #1): a password-style key whose VALUE is natural
71
+ * language — an error message, validation copy, UI string — is almost never a
72
+ * credential. e.g. a "New password must differ from recent passwords"
73
+ * validation message assigned to such a key.
74
+ *
75
+ * We don't drop these (a real secret that happens to read like prose must still
76
+ * surface — false-green is the failure mode this tool exists to prevent); we
77
+ * downgrade them to a LOW-CONFIDENCE warning the agent can suppress inline,
78
+ * instead of a blocking error. Heuristic per the field report: ≥3 words, OR
79
+ * ≥2 internal spaces, OR ends in sentence punctuation.
80
+ *
81
+ * @param {string} value - the literal inside the quotes
82
+ */
83
+ function looksLikeProse(value) {
84
+ if (!value) return false;
85
+ const v = value.trim();
86
+ const words = v.split(/\s+/).filter(Boolean);
87
+ // Multi-word natural language (validation messages, UI copy, sentences).
88
+ if (words.length >= 3) return true;
89
+ // A 2-word sentence fragment ending in terminal punctuation — but NOT a
90
+ // single token like "SuperSecretPassword!" (strong passwords end in !/? too,
91
+ // so terminal punctuation ALONE must never reclassify a one-word value).
92
+ if (words.length >= 2 && /[.!?]$/.test(v)) return true;
93
+ return false;
94
+ }
95
+
96
+ /** Pull the first quoted literal out of a matched secret expression. */
97
+ function quotedValue(matchStr) {
98
+ const m = matchStr.match(/['"]([^'"]*)['"]/);
99
+ return m ? m[1] : '';
100
+ }
59
101
 
102
+ export function validateSecurity(projectDir, config) {
103
+ /** @type {import('../findings.mjs').Finding[]} */
60
104
  const findings = [];
105
+ let passed = 0;
106
+ let total = 0;
61
107
  let scanned = 0;
108
+ let realSecretCount = 0;
62
109
 
63
110
  walkDir(projectDir, (filePath) => {
64
111
  const ext = extname(filePath);
@@ -89,25 +136,58 @@ export function validateSecurity(projectDir, config) {
89
136
  // Lazily initialize lines only when a match is found
90
137
  if (!lines) lines = content.split('\n');
91
138
 
92
- // Find the line containing this match for context-aware filtering
93
- const matchPos = match.index;
94
- let charCount = 0;
95
- let matchLine = '';
96
- for (const line of lines) {
97
- charCount += line.length + 1; // +1 for newline
98
- if (charCount > matchPos) {
99
- matchLine = line;
100
- break;
101
- }
102
- }
139
+ // 1-based line number + the line above (for inline-pragma suppression).
140
+ const lineNo = content.slice(0, match.index).split('\n').length;
141
+ const matchLine = lines[lineNo - 1] || '';
142
+ const prevLine = lines[lineNo - 2] || '';
103
143
 
104
144
  // Skip known-safe placeholder/example values, but keep scanning for a
105
145
  // real one further down the file.
106
146
  if (isSafePlaceholder(matchLine, match[0])) continue;
107
147
 
108
- findings.push({ file: relPath, label, match: match[0].substring(0, 30) + '...' });
148
+ const code = LABEL_TO_CODE[label];
149
+
150
+ // v0.27 (#8): honour an inline `// docguard:ignore SEC00x` pragma on the
151
+ // line or the line above — per-line suppression instead of blinding the
152
+ // whole file via `securityIgnore`.
153
+ if (code && lineSuppresses(code, matchLine, prevLine)) break;
154
+
155
+ const location = `${relPath}:${lineNo}`;
156
+ const value = quotedValue(match[0]);
157
+ const isProse = looksLikeProse(value);
158
+
159
+ if (isProse) {
160
+ // v0.27 (#1): natural-language value → low-confidence warning, not a
161
+ // blocking error. Still surfaced (no false-green), still suppressible,
162
+ // and now reportable via `docguard feedback`.
163
+ findings.push(mkFinding({
164
+ code, validator: 'security', severity: 'warn', confidence: 'low',
165
+ message: `${location}: possible ${label} — but the value reads like natural-language text (likely UI copy / a validation message, not a credential)`,
166
+ location,
167
+ suggestion: {
168
+ kind: 'suppress',
169
+ text: 'If this is UI copy or a message and not a real secret, suppress it inline.',
170
+ pragma: `// docguard:ignore ${code} — UI copy, not a credential`,
171
+ },
172
+ reportable: true,
173
+ redactedContext: `${label} pattern fired on a value that is natural-language text (~${value.trim().split(/\s+/).filter(Boolean).length} words). Literal omitted.`,
174
+ }));
175
+ } else {
176
+ realSecretCount++;
177
+ findings.push(mkFinding({
178
+ code, validator: 'security', severity: 'error', confidence: 'high',
179
+ message: `${location}: possible ${label} found`,
180
+ location,
181
+ suggestion: {
182
+ kind: 'fix',
183
+ text: 'Move the secret to an environment variable and read it via process.env / the platform secret store. Never commit credentials.',
184
+ command: code ? `docguard explain ${code}` : undefined,
185
+ pragma: code ? `// docguard:ignore ${code} — reason (only if a confirmed false positive)` : undefined,
186
+ },
187
+ }));
188
+ }
109
189
  // One finding per (file, label) is enough — the reported message is
110
- // identical for repeats and we've already proven a real secret exists.
190
+ // identical for repeats and we've already proven a match exists.
111
191
  break;
112
192
  }
113
193
  }
@@ -116,35 +196,41 @@ export function validateSecurity(projectDir, config) {
116
196
  // Only count the secret scan as a passed check if we actually scanned files.
117
197
  // An empty scan that reports "no secrets" is a dangerous false ✅ — surface it.
118
198
  if (scanned > 0) {
119
- results.total++;
120
- if (findings.length === 0) {
121
- results.passed++;
122
- } else {
123
- for (const f of findings) {
124
- results.errors.push(`${f.file}: possible ${f.label} found`);
125
- }
126
- }
199
+ total++;
200
+ // Low-confidence (prose) findings do not fail the check — only real secrets do.
201
+ if (realSecretCount === 0) passed++;
127
202
  } else {
128
- results.warnings.push(
129
- 'No source files were scanned for secrets — check config.sourceRoot / ignore patterns'
130
- );
203
+ findings.push(mkFinding({
204
+ code: 'SEC011', validator: 'security', severity: 'warn', confidence: 'high',
205
+ message: 'No source files were scanned for secrets — check config.sourceRoot / ignore patterns',
206
+ suggestion: { kind: 'review', text: 'Verify config.sourceRoot and ignore patterns actually include your source tree.' },
207
+ }));
131
208
  }
132
209
 
133
210
  // Check .gitignore includes .env
134
- results.total++;
211
+ total++;
135
212
  const gitignorePath = resolve(projectDir, '.gitignore');
136
213
  if (existsSync(gitignorePath)) {
137
214
  const gitignore = readFileSync(gitignorePath, 'utf-8');
138
215
  if (gitignore.includes('.env') || gitignore.includes('.env.local')) {
139
- results.passed++;
216
+ passed++;
140
217
  } else {
141
- results.warnings.push('.gitignore does not include .env — secrets may be committed');
218
+ findings.push(mkFinding({
219
+ code: 'SEC010', validator: 'security', severity: 'warn', confidence: 'high',
220
+ message: '.gitignore does not include .env — secrets may be committed',
221
+ location: '.gitignore',
222
+ suggestion: { kind: 'fix', text: 'Add `.env` and `.env.local` to .gitignore.' },
223
+ }));
142
224
  }
143
225
  } else {
144
- results.warnings.push('No .gitignore found — secrets may be committed');
226
+ findings.push(mkFinding({
227
+ code: 'SEC010', validator: 'security', severity: 'warn', confidence: 'high',
228
+ message: 'No .gitignore found — secrets may be committed',
229
+ suggestion: { kind: 'fix', text: 'Create a .gitignore that excludes `.env` and `.env.local`.' },
230
+ }));
145
231
  }
146
232
 
147
- return results;
233
+ return resultFromFindings(findings, { passed, total });
148
234
  }
149
235
 
150
236
  function walkDir(dir, callback) {
@@ -262,6 +262,10 @@ function loadTrackingDocs(projectDir, config) {
262
262
  const trackingFiles = [
263
263
  'ROADMAP.md', 'CURRENT-STATE.md', 'TODO.md', 'BACKLOG.md',
264
264
  'docs-canonical/ARCHITECTURE.md', 'CHANGELOG.md',
265
+ // v0.27 (field report #6): many projects keep the roadmap/backlog under
266
+ // docs-canonical/ — a TODO tracked there was wrongly read as "untracked".
267
+ 'docs-canonical/ROADMAP.md', 'docs-canonical/CURRENT-STATE.md',
268
+ 'docs-canonical/BACKLOG.md', 'docs-canonical/TODO.md',
265
269
  ...(config.todoTracking?.trackingFiles || []),
266
270
  ];
267
271
 
@@ -47,15 +47,33 @@ const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
47
47
  function applyReplaceCount(projectDir, fix) {
48
48
  const full = resolve(projectDir, fix.file);
49
49
  if (!existsSync(full)) return { applied: false };
50
+ // Bug #2 (fail-closed): NEVER overwrite a number without provenance proving
51
+ // the "actual" describes the SAME subject. The Metrics-Consistency validator
52
+ // stamps `actualSource` (e.g. "docguard.guard.checks") only for claims it
53
+ // verified are bound to DocGuard. A fix lacking it is refused rather than risk
54
+ // corrupting a correct, unrelated number.
55
+ if (!fix.actualSource) {
56
+ return { applied: false, detail: `${fix.file}: skipped "${fix.found} ${fix.label}" → "${fix.actual}" — no provenance (actualSource) to prove same subject` };
57
+ }
50
58
  const content = readFileSync(full, 'utf-8');
51
59
  // v0.15.2 hotfix: case-insensitive label match. Mirrors the validator's
52
60
  // detection regex (which is `gi`). Without `i` here, the applier would
53
61
  // skip "21 Validators" (capitalized) even though Metrics-Consistency
54
62
  // detected it — leaving the user with a warning they couldn't auto-fix.
55
- // The /docguard.diagnose run on canonical-spec-kit surfaced this.
56
63
  const re = new RegExp(`\\b${esc(fix.found)}(\\s+(?:automated\\s+)?${esc(fix.label)}\\b)`, 'gi');
57
- const next = content.replace(re, `${fix.actual}$1`);
58
- if (next === content) return { applied: false };
64
+ // Only rewrite occurrences on a DocGuard-bound line (same predicate as the
65
+ // validator's subject-binding) so a stray "<found> <label>" elsewhere in the
66
+ // file is never collateral-damaged by the global replace.
67
+ let changed = false;
68
+ const next = content.replace(re, (m, tail, offset, str) => {
69
+ const lineStart = str.lastIndexOf('\n', offset) + 1;
70
+ let lineEnd = str.indexOf('\n', offset);
71
+ if (lineEnd === -1) lineEnd = str.length;
72
+ if (!/docguard/i.test(str.slice(lineStart, lineEnd))) return m; // not bound → leave untouched
73
+ changed = true;
74
+ return `${fix.actual}${tail}`;
75
+ });
76
+ if (!changed || next === content) return { applied: false };
59
77
  writeFileSync(full, next, 'utf-8');
60
78
  return { applied: true, detail: `${fix.file}: "${fix.found} ${fix.label}" → "${fix.actual} ${fix.label}"` };
61
79
  }
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: "docguard"
5
5
  name: "DocGuard — CDD Enforcement"
6
- version: "0.25.1"
6
+ version: "0.27.0"
7
7
  description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. One pinned runtime dependency (@babel/parser); pure Node.js otherwise."
8
8
  author: "Ricardo Accioly"
9
9
  repository: "https://github.com/raccioly/docguard"
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.25.0
9
+ version: 0.27.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.25.0 -->
12
+ <!-- docguard:version: 0.27.0 -->
13
13
 
14
14
  # DocGuard Fix Skill
15
15
 
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
7
7
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
8
8
  metadata:
9
9
  author: docguard
10
- version: 0.25.0
10
+ version: 0.27.0
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.25.0 -->
13
+ <!-- docguard:version: 0.27.0 -->
14
14
 
15
15
  # DocGuard Guard Skill
16
16
 
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.25.0
9
+ version: 0.27.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.25.0 -->
12
+ <!-- docguard:version: 0.27.0 -->
13
13
 
14
14
  # DocGuard Review Skill
15
15
 
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.25.0
9
+ version: 0.27.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.25.0 -->
12
+ <!-- docguard:version: 0.27.0 -->
13
13
 
14
14
  # DocGuard Score Skill
15
15
 
@@ -4,10 +4,10 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
4
4
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
5
5
  metadata:
6
6
  author: docguard
7
- version: 0.25.0
7
+ version: 0.27.0
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
- <!-- docguard:version: 0.25.0 -->
10
+ <!-- docguard:version: 0.27.0 -->
11
11
 
12
12
  # DocGuard Sync Skill
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.25.1",
3
+ "version": "0.27.0",
4
4
  "description": "The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project documentation.",
5
5
  "type": "module",
6
6
  "bin": {