promptwheel 0.4.2 → 0.4.3
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/README.md +2 -2
- package/bin/promptwheel.mjs +24 -3
- package/package.json +32 -8
- package/packs/security/README.md +52 -0
- package/packs/security/pack.json +12 -0
- package/packs/security/patterns.json +209 -0
- package/packs/security/scan.mjs +57 -0
package/README.md
CHANGED
|
@@ -242,7 +242,7 @@ The engine is one importable file; pure helpers are exported for unit tests, the
|
|
|
242
242
|
- [x] `promptwheel init` + presets — zero-config onboarding
|
|
243
243
|
- [x] `insights` — reward-stream aggregation (Phase-5 seed)
|
|
244
244
|
- [x] `--detect-gaming` — reward-hack detection: re-prove the win from source edits alone + `antihack` preset
|
|
245
|
-
- [x] npm publish — `promptwheel@0.4.
|
|
245
|
+
- [x] npm publish — `promptwheel@0.4.2` (the lead magnet)
|
|
246
246
|
- [x] outcome-curated learning + UCB work-discovery — `playbook` + `suggest` + the compounding A/B harness (**experimental**; compounding *claims* stay gated on real-data proof — see [docs/LEARNING.md](docs/LEARNING.md))
|
|
247
247
|
|
|
248
|
-
> Status: **published** (npm `promptwheel`, v0.4.
|
|
248
|
+
> Status: **published** (npm `promptwheel`, v0.4.2) — all core phases built. Lineage: CommandLayer → BlockSpool → PromptWheel (orchestrator, archived) → **PromptWheel (outcome gate)**.
|
package/bin/promptwheel.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// promptwheel run [--base R] [--head R] [--repeat N] [--json]
|
|
12
12
|
|
|
13
13
|
import { execSync, execFileSync } from 'node:child_process';
|
|
14
|
-
import { readFileSync, writeFileSync, existsSync, mkdtempSync, symlinkSync, rmSync, mkdirSync, appendFileSync, realpathSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdtempSync, symlinkSync, rmSync, mkdirSync, appendFileSync, realpathSync, readdirSync, statSync, copyFileSync } from 'node:fs';
|
|
15
15
|
import { tmpdir } from 'node:os';
|
|
16
16
|
import { join, dirname, isAbsolute, relative } from 'node:path';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
@@ -87,13 +87,19 @@ function runMetric(cwd, m, env) {
|
|
|
87
87
|
try {
|
|
88
88
|
stdout = execSync(m.cmd, { cwd, env: env || process.env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: (m.timeoutSec ?? 300) * 1000 });
|
|
89
89
|
} catch (e) { code = e.status ?? 1; stdout = `${e.stdout || ''}${e.stderr || ''}`; }
|
|
90
|
-
return extract(stdout, code, m.extract);
|
|
90
|
+
return extract(stdout, code, m.extract, m.allowNonzero);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
// extract modes: 'number' (last number, default) | 'lines' | 'exit' | {regex}
|
|
94
|
-
|
|
94
|
+
// A FAILED command is unmeasurable (null -> the metric goes inert, guards
|
|
95
|
+
// fail loudly) — error text often contains numbers ("Node.js v22") and
|
|
96
|
+
// silently measuring those produced identical junk on both refs = a false
|
|
97
|
+
// green. 'exit' mode consumes nonzero by design; anything else needs the
|
|
98
|
+
// per-metric opt-in `allowNonzero: true`.
|
|
99
|
+
function extract(stdout, code, mode, allowNonzero) {
|
|
95
100
|
mode = mode || 'number';
|
|
96
101
|
if (mode === 'exit') return code === 0 ? 1 : 0;
|
|
102
|
+
if (code !== 0 && !allowNonzero) return null;
|
|
97
103
|
if (mode === 'lines') return stdout.split('\n').filter((l) => l.trim()).length;
|
|
98
104
|
if (mode && typeof mode === 'object' && mode.regex) {
|
|
99
105
|
const mm = new RegExp(mode.regex).exec(stdout);
|
|
@@ -647,6 +653,8 @@ const PRESETS = {
|
|
|
647
653
|
metrics: [{ name: 'lint_errors', cmd: LINT_CMD, extract: 'number', direction: 'down', guard: false }] },
|
|
648
654
|
'bundle-size': { desc: 'track: build output size in kB',
|
|
649
655
|
metrics: [{ name: 'bundle_kb', cmd: 'du -sk dist 2>/dev/null | cut -f1 || echo 0', extract: 'number', direction: 'down', guard: false }] },
|
|
656
|
+
'security': { desc: 'gate: security-invariant violations must not climb (CORS, secrets, cookies, payment; see packs/security)',
|
|
657
|
+
metrics: [{ name: 'security_findings', cmd: 'node packs/security/scan.mjs .', extract: 'number', direction: 'down', guard: true }] },
|
|
650
658
|
'llm-eval': { desc: 'gate: AI-feature eval pass-rate + est $/run (see examples/reliability-sprint)',
|
|
651
659
|
metrics: [
|
|
652
660
|
{ name: 'eval_pass_rate', cmd: 'node eval.mjs', extract: 'number', direction: 'up', guard: true },
|
|
@@ -717,6 +725,19 @@ function init(argv) {
|
|
|
717
725
|
if (!p) { console.error(`unknown preset "${presetName}" — try: promptwheel init --list`); process.exit(2); }
|
|
718
726
|
metrics = p.metrics || [{ name: 'tests_pass', cmd: detectTestCmd(repo), extract: 'exit', direction: 'pass', guard: true }];
|
|
719
727
|
metrics = metrics.map((m) => m.cmd === '__TESTCMD__' ? { ...m, cmd: detectTestCmd(repo) } : m); // fill the preset's target
|
|
728
|
+
// pack-backed presets: the referenced scripts live in THIS package, not
|
|
729
|
+
// the user's repo — copy them in so the generated config is
|
|
730
|
+
// self-contained and committable (a bare reference silently 404'd)
|
|
731
|
+
metrics = metrics.map((m) => {
|
|
732
|
+
const pk = /(^|\s)(packs\/[\w-]+)\//.exec(m.cmd);
|
|
733
|
+
if (!pk) return m;
|
|
734
|
+
const src = fileURLToPath(new URL(`../${pk[2]}/`, import.meta.url));
|
|
735
|
+
const dst = join(repo, '.promptwheel', pk[2]);
|
|
736
|
+
mkdirSync(dst, { recursive: true });
|
|
737
|
+
for (const f of readdirSync(src)) copyFileSync(join(src, f), join(dst, f));
|
|
738
|
+
console.log(` ✓ installed ${pk[2]} → .promptwheel/${pk[2]} (commit it with the config)`);
|
|
739
|
+
return { ...m, cmd: m.cmd.replaceAll(pk[2] + '/', `.promptwheel/${pk[2]}/`) };
|
|
740
|
+
});
|
|
720
741
|
note = presetName;
|
|
721
742
|
} else {
|
|
722
743
|
// default = the headline posture: guarded tests + the antihack tripwires, so the FIRST
|
package/package.json
CHANGED
|
@@ -1,22 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptwheel",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Add a referee to your coding loop
|
|
3
|
+
"version": "0.4.3",
|
|
4
|
+
"description": "Add a referee to your coding loop \u2014 a deterministic, zero-LLM CLI that checks every change earned its win: it re-proves the result from the agent's source edits alone, so a green that came from editing the test reads GAMED. Runs in CI or a Claude Code hook.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"promptwheel": "bin/promptwheel.mjs",
|
|
8
8
|
"pw": "bin/promptwheel.mjs"
|
|
9
9
|
},
|
|
10
|
-
"files": [
|
|
11
|
-
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"packs"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
12
17
|
"scripts": {
|
|
13
18
|
"gate": "node bin/promptwheel.mjs run",
|
|
14
19
|
"test": "node --test"
|
|
15
20
|
},
|
|
16
|
-
"repository": {
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/promptwheel-ai/promptwheel.git"
|
|
24
|
+
},
|
|
17
25
|
"homepage": "https://github.com/promptwheel-ai/promptwheel#readme",
|
|
18
|
-
"bugs": {
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/promptwheel-ai/promptwheel/issues"
|
|
28
|
+
},
|
|
19
29
|
"author": "Matthew Owens",
|
|
20
30
|
"license": "MIT",
|
|
21
|
-
"keywords": [
|
|
22
|
-
|
|
31
|
+
"keywords": [
|
|
32
|
+
"agent",
|
|
33
|
+
"ci",
|
|
34
|
+
"regression",
|
|
35
|
+
"metrics",
|
|
36
|
+
"verification",
|
|
37
|
+
"ai-coding",
|
|
38
|
+
"outcome",
|
|
39
|
+
"evals",
|
|
40
|
+
"loop",
|
|
41
|
+
"promptwheel",
|
|
42
|
+
"reward-hacking",
|
|
43
|
+
"detect-gaming",
|
|
44
|
+
"reward-hack-detection"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# PromptWheel security guard-pack
|
|
2
|
+
|
|
3
|
+
Gate your AI coding loop on **security invariants**, not just tests. When an agent
|
|
4
|
+
writes backend code, "tests pass" says nothing about whether it shipped a CORS
|
|
5
|
+
wildcard, a client-controlled payment amount, or a hardcoded secret. This pack
|
|
6
|
+
adds a `security_findings` metric that must not climb — and because it's a
|
|
7
|
+
PromptWheel metric, the win has to survive `--detect-gaming`: an agent can't
|
|
8
|
+
lower the count by editing the pattern file or a config, only by fixing the code.
|
|
9
|
+
|
|
10
|
+
## Use it
|
|
11
|
+
|
|
12
|
+
In your `promptwheel.config.json`:
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{ "extends": ["packs/security/pack.json"] }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then gate as usual:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
promptwheel run --working # fails if security_findings increased
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
A finding-count that drops because you fixed the code is a `PASS`; a "fix" that
|
|
25
|
+
only survives with test/config/pattern edits reverted is `GAMED`.
|
|
26
|
+
|
|
27
|
+
## What it checks (v0.1 — 8 high-precision invariants)
|
|
28
|
+
|
|
29
|
+
Decanted from [securitychecks](https://securitychecks.ai)' "recommended" tier —
|
|
30
|
+
the regex-portable subset, chosen for near-zero false positives:
|
|
31
|
+
|
|
32
|
+
| Invariant | Severity |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| CORS wildcard origin | P0 |
|
|
35
|
+
| CORS origin reflection | P0 |
|
|
36
|
+
| Insecure session cookie (`secure:false`) | P0 |
|
|
37
|
+
| Payment amount from client input | P0 |
|
|
38
|
+
| Hardcoded secret in a sensitive variable | P0 |
|
|
39
|
+
| NPM token committed in `.npmrc` | P0 |
|
|
40
|
+
| Template autoescape disabled (XSS) | P0 |
|
|
41
|
+
| S3 presigned upload without content validation | P2 |
|
|
42
|
+
|
|
43
|
+
Each pattern ships with its own false-positive guards (the `exclude` regexes).
|
|
44
|
+
|
|
45
|
+
## Scope, honestly
|
|
46
|
+
|
|
47
|
+
This is the **regex-portable** slice. The other ~75 recommended invariants
|
|
48
|
+
(auth-enforcement, authz-revocation, tenant-isolation, dataflow-tainted sinks)
|
|
49
|
+
need the AST + dataflow engine and are the follow-up — see the decant note in
|
|
50
|
+
`explorations/`. The point of v0.1 is the architecture: security invariants are
|
|
51
|
+
now a first-class, un-gameable PromptWheel metric. Zero dependencies; the scanner
|
|
52
|
+
is one file (`scan.mjs`).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "PromptWheel security guard-pack. Decanted from securitychecks (recommended tier). Use via { \"extends\": [\"packs/security/pack.json\"] } in your promptwheel.config.json. Gates on the count of security-invariant violations (direction: down = lower is better; guard: true = fail if it climbs). Because it's a promptwheel metric, the win must survive --detect-gaming: an agent can't lower the count by editing the pattern file or a config, only by fixing the code.",
|
|
3
|
+
"metrics": [
|
|
4
|
+
{
|
|
5
|
+
"name": "security_findings",
|
|
6
|
+
"cmd": "node packs/security/scan.mjs .",
|
|
7
|
+
"extract": "number",
|
|
8
|
+
"direction": "down",
|
|
9
|
+
"guard": true
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pack": "security",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"source": "securitychecks recommended tier (regex-portable subset)",
|
|
5
|
+
"patterns": [
|
|
6
|
+
{
|
|
7
|
+
"id": "DATAFLOW.UNTRUSTED.XSS",
|
|
8
|
+
"name": "Template Engine Autoescape Disabled",
|
|
9
|
+
"severity": "P0",
|
|
10
|
+
"match": "autoescape\\s*:\\s*false",
|
|
11
|
+
"exclude": [],
|
|
12
|
+
"message": "Template engine autoescape disabled \u2014 enables stored XSS attacks",
|
|
13
|
+
"fix": "Enable autoescape or sanitize all user input before rendering",
|
|
14
|
+
"files": [
|
|
15
|
+
"**/*.ts",
|
|
16
|
+
"**/*.js",
|
|
17
|
+
"!**/__tests__/**",
|
|
18
|
+
"!**/*.test.*",
|
|
19
|
+
"!**/*.spec.*",
|
|
20
|
+
"!**/test/**",
|
|
21
|
+
"!**/tests/**",
|
|
22
|
+
"!**/__mocks__/**",
|
|
23
|
+
"!**/fixtures/**",
|
|
24
|
+
"!**/www/**",
|
|
25
|
+
"!**/docs/**",
|
|
26
|
+
"!**/documentation/**"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "SESSION.INSECURE_COOKIE",
|
|
31
|
+
"name": "Insecure Session Cookie",
|
|
32
|
+
"severity": "P0",
|
|
33
|
+
"match": "secure\\s*:\\s*false",
|
|
34
|
+
"exclude": [],
|
|
35
|
+
"message": "Session cookies must have secure and httpOnly flags",
|
|
36
|
+
"fix": "Verify this is not a false positive",
|
|
37
|
+
"files": [
|
|
38
|
+
"**/*.ts",
|
|
39
|
+
"**/*.js",
|
|
40
|
+
"**/*.tsx",
|
|
41
|
+
"**/*.jsx",
|
|
42
|
+
"!**/__tests__/**",
|
|
43
|
+
"!**/*.test.*",
|
|
44
|
+
"!**/*.spec.*",
|
|
45
|
+
"!**/test/**",
|
|
46
|
+
"!**/tests/**",
|
|
47
|
+
"!**/__mocks__/**",
|
|
48
|
+
"!**/fixtures/**",
|
|
49
|
+
"!**/e2e/**",
|
|
50
|
+
"!**/playwright/**",
|
|
51
|
+
"!**/cypress/**",
|
|
52
|
+
"!**/test-utils/**",
|
|
53
|
+
"!**/*.e2e.*",
|
|
54
|
+
"!**/e2e-*/**"
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"id": "AUTHZ.CORS_WILDCARD",
|
|
59
|
+
"name": "CORS Wildcard Origin",
|
|
60
|
+
"severity": "P0",
|
|
61
|
+
"match": "Access-Control-Allow-Origin.*\\*|origin\\s*:\\s*\\*|origin:\\s*true",
|
|
62
|
+
"exclude": [
|
|
63
|
+
"!credentials"
|
|
64
|
+
],
|
|
65
|
+
"message": "Wildcard origin with credentials exposes user data",
|
|
66
|
+
"fix": "Verify this is not a false positive",
|
|
67
|
+
"files": [
|
|
68
|
+
"**/*.ts",
|
|
69
|
+
"**/*.js",
|
|
70
|
+
"**/*.tsx",
|
|
71
|
+
"**/*.jsx",
|
|
72
|
+
"!**/__tests__/**",
|
|
73
|
+
"!**/*.test.*",
|
|
74
|
+
"!**/*.spec.*",
|
|
75
|
+
"!**/test/**",
|
|
76
|
+
"!**/tests/**",
|
|
77
|
+
"!**/__mocks__/**",
|
|
78
|
+
"!**/fixtures/**",
|
|
79
|
+
"!**/*.tsx",
|
|
80
|
+
"!**/*.jsx",
|
|
81
|
+
"!**/components/**"
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"id": "AUTHZ.CORS_REFLECT",
|
|
86
|
+
"name": "CORS Origin Reflection",
|
|
87
|
+
"severity": "P0",
|
|
88
|
+
"match": "(?:Access-Control-Allow-Origin|res\\.(?:set|setHeader|header)\\(['\"]Access-Control).*(?:req\\.headers\\.origin|headers\\.get\\(['\"]origin['\"]\\))",
|
|
89
|
+
"exclude": [],
|
|
90
|
+
"message": "Reflecting origin is equivalent to wildcard \u2014 validate against allowlist",
|
|
91
|
+
"fix": "Verify this is not a false positive",
|
|
92
|
+
"files": [
|
|
93
|
+
"**/*.ts",
|
|
94
|
+
"**/*.js",
|
|
95
|
+
"**/*.tsx",
|
|
96
|
+
"**/*.jsx",
|
|
97
|
+
"!**/__tests__/**",
|
|
98
|
+
"!**/*.test.*",
|
|
99
|
+
"!**/*.spec.*",
|
|
100
|
+
"!**/test/**",
|
|
101
|
+
"!**/tests/**",
|
|
102
|
+
"!**/__mocks__/**",
|
|
103
|
+
"!**/fixtures/**"
|
|
104
|
+
]
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"id": "PAY.CLIENT_AMOUNT",
|
|
108
|
+
"name": "Payment Amount From Client",
|
|
109
|
+
"severity": "P0",
|
|
110
|
+
"match": "amount.*req\\.body|price.*req\\.body|total.*body\\.amount",
|
|
111
|
+
"exclude": [
|
|
112
|
+
"server|database|catalog"
|
|
113
|
+
],
|
|
114
|
+
"message": "Calculate payment amounts server-side, not from client input",
|
|
115
|
+
"fix": "Verify this is not a false positive",
|
|
116
|
+
"files": [
|
|
117
|
+
"**/*.ts",
|
|
118
|
+
"**/*.js",
|
|
119
|
+
"**/*.tsx",
|
|
120
|
+
"**/*.jsx",
|
|
121
|
+
"!**/__tests__/**",
|
|
122
|
+
"!**/*.test.*",
|
|
123
|
+
"!**/*.spec.*",
|
|
124
|
+
"!**/test/**",
|
|
125
|
+
"!**/tests/**",
|
|
126
|
+
"!**/__mocks__/**",
|
|
127
|
+
"!**/fixtures/**"
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "CONFIG.NPMRC_TOKEN",
|
|
132
|
+
"name": "NPM Token in .npmrc",
|
|
133
|
+
"severity": "P0",
|
|
134
|
+
"match": "_authToken=|//registry.*:_auth",
|
|
135
|
+
"exclude": [
|
|
136
|
+
"\\$\\{NPM_TOKEN\\}|process\\.env"
|
|
137
|
+
],
|
|
138
|
+
"message": "NPM tokens should come from environment variables",
|
|
139
|
+
"fix": "Verify this is not a false positive",
|
|
140
|
+
"files": [
|
|
141
|
+
"**/*",
|
|
142
|
+
"!**/__tests__/**",
|
|
143
|
+
"!**/*.test.*",
|
|
144
|
+
"!**/*.spec.*",
|
|
145
|
+
"!**/test/**",
|
|
146
|
+
"!**/tests/**",
|
|
147
|
+
"!**/__mocks__/**",
|
|
148
|
+
"!**/fixtures/**"
|
|
149
|
+
]
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"id": "VALID.UPLOAD_NOCHECK",
|
|
153
|
+
"name": "S3 Presigned Upload Without Content Validation",
|
|
154
|
+
"severity": "P2",
|
|
155
|
+
"match": "(?:createPresignedPost|getSignedUrl|PutObjectCommand)\\s*\\(",
|
|
156
|
+
"exclude": [
|
|
157
|
+
"ContentType|content-type|Content-Length|content-length|Conditions|conditions",
|
|
158
|
+
"allowedTypes|fileFilter|mimetype|maxSize|maxFileSize",
|
|
159
|
+
"@example|Example:|```|description:|guidance",
|
|
160
|
+
"GetObjectCommand|getObject|HeadObjectCommand|DeleteObjectCommand|download|Download|read|Read",
|
|
161
|
+
"generateSignedUrl.*get|getSignedUrl.*getObject"
|
|
162
|
+
],
|
|
163
|
+
"message": "S3 presigned upload URL has no content type or size conditions \u2014 any file type/size can be uploaded",
|
|
164
|
+
"fix": "Add Conditions to presigned POST or ContentType/ContentLength constraints",
|
|
165
|
+
"files": [
|
|
166
|
+
"**/*.ts",
|
|
167
|
+
"**/*.js",
|
|
168
|
+
"!**/*.tsx",
|
|
169
|
+
"!**/*.jsx",
|
|
170
|
+
"!**/components/**",
|
|
171
|
+
"!**/frontend/**",
|
|
172
|
+
"!**/public/**",
|
|
173
|
+
"!**/__tests__/**",
|
|
174
|
+
"!**/*.test.*",
|
|
175
|
+
"!**/*.spec.*",
|
|
176
|
+
"!**/test/**",
|
|
177
|
+
"!**/tests/**",
|
|
178
|
+
"!**/__mocks__/**",
|
|
179
|
+
"!**/fixtures/**"
|
|
180
|
+
]
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"id": "SECRETS.HARDCODED",
|
|
184
|
+
"name": "Hardcoded Secret in Sensitive Variable",
|
|
185
|
+
"severity": "P0",
|
|
186
|
+
"match": "(?:const|let|var)\\s+(?:[A-Z_]*(?:SECRET|JWT_SECRET|API_KEY|AUTH_TOKEN|PRIVATE_KEY|SIGNING_KEY|ENCRYPTION_KEY)[A-Z_]*)\\s*=\\s*['\"][^'\"]{8,}['\"]",
|
|
187
|
+
"exclude": [
|
|
188
|
+
"process\\.env|import\\.meta\\.env|getenv|\\$\\{|placeholder|changeme|YOUR_|TODO|FIXME|example|EXAMPLE"
|
|
189
|
+
],
|
|
190
|
+
"message": "Secret value hardcoded in source code \u2014 use environment variables",
|
|
191
|
+
"fix": "Verify this is not a placeholder or example value",
|
|
192
|
+
"files": [
|
|
193
|
+
"**/*.ts",
|
|
194
|
+
"**/*.js",
|
|
195
|
+
"!**/__tests__/**",
|
|
196
|
+
"!**/*.test.*",
|
|
197
|
+
"!**/*.spec.*",
|
|
198
|
+
"!**/test/**",
|
|
199
|
+
"!**/tests/**",
|
|
200
|
+
"!**/__mocks__/**",
|
|
201
|
+
"!**/fixtures/**",
|
|
202
|
+
"!**/sample/**",
|
|
203
|
+
"!**/samples/**",
|
|
204
|
+
"!**/example/**",
|
|
205
|
+
"!**/examples/**"
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PromptWheel security guard-pack scanner — zero-dep.
|
|
3
|
+
// Decanted from securitychecks' "recommended" tier (the regex-portable subset).
|
|
4
|
+
// Emits the count of security-invariant violations to stdout (the metric value).
|
|
5
|
+
// The full AST+dataflow engine (the other 75 recommended patterns) is the follow-up.
|
|
6
|
+
//
|
|
7
|
+
// Usage: node scan.mjs [path] → prints the finding count (for `promptwheel` metrics)
|
|
8
|
+
// node scan.mjs [path] --list → prints each finding (file:line — id — message)
|
|
9
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { join, extname } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const ROOT = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : '.';
|
|
14
|
+
const LIST = process.argv.includes('--list');
|
|
15
|
+
const PACK = JSON.parse(readFileSync(join(fileURLToPath(new URL('.', import.meta.url)), 'patterns.json'), 'utf8'));
|
|
16
|
+
|
|
17
|
+
const SKIP = /(^|\/)(node_modules|\.git|dist|build|\.next|coverage|vendor)(\/|$)/;
|
|
18
|
+
const CODE = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
19
|
+
const CONFIG_FILES = /(^|\/)\.npmrc$/;
|
|
20
|
+
|
|
21
|
+
function* walk(dir) {
|
|
22
|
+
let entries;
|
|
23
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
24
|
+
for (const e of entries) {
|
|
25
|
+
const p = join(dir, e);
|
|
26
|
+
if (SKIP.test(p)) continue;
|
|
27
|
+
let st;
|
|
28
|
+
try { st = statSync(p); } catch { continue; }
|
|
29
|
+
if (st.isDirectory()) yield* walk(p);
|
|
30
|
+
else if (CODE.has(extname(p)) || CONFIG_FILES.test(p)) yield p;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const findings = [];
|
|
35
|
+
for (const file of walk(ROOT)) {
|
|
36
|
+
let text;
|
|
37
|
+
try { text = readFileSync(file, 'utf8'); } catch { continue; }
|
|
38
|
+
const lines = text.split('\n');
|
|
39
|
+
for (const pat of PACK.patterns) {
|
|
40
|
+
let re;
|
|
41
|
+
try { re = new RegExp(pat.match, 'i'); } catch { continue; }
|
|
42
|
+
const excludes = (pat.exclude || []).map((x) => { try { return new RegExp(x, 'i'); } catch { return null; } }).filter(Boolean);
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i];
|
|
45
|
+
if (!re.test(line)) continue;
|
|
46
|
+
if (excludes.some((ex) => ex.test(line))) continue; // pattern's own false-positive guards
|
|
47
|
+
findings.push({ file, line: i + 1, id: pat.id, sev: pat.severity, msg: pat.message });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (LIST) {
|
|
53
|
+
for (const f of findings) console.error(` [${f.sev}] ${f.file}:${f.line} — ${f.id} — ${f.msg}`);
|
|
54
|
+
console.error(`\n${findings.length} security-invariant violation(s)`);
|
|
55
|
+
}
|
|
56
|
+
// the metric value: lower is better, 0 is clean
|
|
57
|
+
process.stdout.write(String(findings.length) + '\n');
|