nerdsystem 0.0.0-stage → 0.0.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.
- package/LICENSE +21 -0
- package/README.md +12 -2
- package/dist/api.d.ts +58 -0
- package/dist/api.js +59 -0
- package/dist/commands/agents.d.ts +9 -0
- package/dist/commands/agents.js +45 -0
- package/dist/commands/auth.d.ts +7 -0
- package/dist/commands/auth.js +25 -0
- package/dist/commands/billing.d.ts +14 -0
- package/dist/commands/billing.js +42 -0
- package/dist/commands/builds.d.ts +12 -0
- package/dist/commands/builds.js +40 -0
- package/dist/commands/check.d.ts +20 -0
- package/dist/commands/check.js +145 -0
- package/dist/commands/db.d.ts +9 -0
- package/dist/commands/db.js +32 -0
- package/dist/commands/deploy.d.ts +27 -0
- package/dist/commands/deploy.js +119 -0
- package/dist/commands/dev.d.ts +3 -0
- package/dist/commands/dev.js +38 -0
- package/dist/commands/doctor.d.ts +4 -0
- package/dist/commands/doctor.js +42 -0
- package/dist/commands/github.d.ts +17 -0
- package/dist/commands/github.js +49 -0
- package/dist/commands/init.d.ts +15 -0
- package/dist/commands/init.js +107 -0
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.js +21 -0
- package/dist/commands/ops.d.ts +29 -0
- package/dist/commands/ops.js +113 -0
- package/dist/commands/org.d.ts +47 -0
- package/dist/commands/org.js +121 -0
- package/dist/commands/verify.d.ts +24 -0
- package/dist/commands/verify.js +50 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +22 -0
- package/dist/gates/attempts.d.ts +18 -0
- package/dist/gates/attempts.js +38 -0
- package/dist/gates/code.d.ts +15 -0
- package/dist/gates/code.js +179 -0
- package/dist/gates/fs.d.ts +7 -0
- package/dist/gates/fs.js +35 -0
- package/dist/gates/index.d.ts +26 -0
- package/dist/gates/index.js +68 -0
- package/dist/gates/rules.d.ts +19 -0
- package/dist/gates/rules.js +58 -0
- package/dist/gates/secrets.d.ts +11 -0
- package/dist/gates/secrets.js +98 -0
- package/dist/gates/suppressions.d.ts +2 -0
- package/dist/gates/suppressions.js +57 -0
- package/dist/gates/text.d.ts +31 -0
- package/dist/gates/text.js +72 -0
- package/dist/gates/tools.d.ts +5 -0
- package/dist/gates/tools.js +69 -0
- package/dist/gates/types.d.ts +72 -0
- package/dist/gates/types.js +18 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +268 -0
- package/dist/project.d.ts +11 -0
- package/dist/project.js +51 -0
- package/dist/util.d.ts +21 -0
- package/dist/util.js +39 -0
- package/package.json +55 -3
- package/rules/semgrep.yml +77 -0
- package/template/.claude/settings.json +12 -0
- package/template/.claude/skills/nerdsystem/SKILL.md +123 -0
- package/template/.cursor/mcp.json +8 -0
- package/template/.cursor/rules/nerdsystem.mdc +124 -0
- package/template/.gemini/settings.json +8 -0
- package/template/.github/copilot-instructions.md +120 -0
- package/template/.github/dependabot.yml +14 -0
- package/template/.mcp.json +8 -0
- package/template/.vscode/mcp.json +9 -0
- package/template/AGENTS.md +120 -0
- package/template/CLAUDE.md +32 -0
- package/template/GEMINI.md +120 -0
- package/template/drizzle.config.ts +7 -0
- package/template/gitignore +7 -0
- package/template/index.html +12 -0
- package/template/migrations/0000_init.sql +7 -0
- package/template/nerd.json +5 -0
- package/template/package.json +33 -0
- package/template/scripts/sync-agents.mjs +28 -0
- package/template/src/client/App.tsx +182 -0
- package/template/src/client/api.ts +42 -0
- package/template/src/client/main.tsx +10 -0
- package/template/src/client/styles.css +16 -0
- package/template/src/client/vite-env.d.ts +1 -0
- package/template/src/server/db/index.ts +7 -0
- package/template/src/server/db/schema.ts +14 -0
- package/template/src/server/index.ts +23 -0
- package/template/src/server/routes/todos.ts +57 -0
- package/template/tsconfig.client.json +18 -0
- package/template/tsconfig.json +4 -0
- package/template/tsconfig.worker.json +18 -0
- package/template/vite.config.ts +7 -0
- package/template/wrangler.jsonc +18 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const SECRET_FIX = "Remove the value from the code. Put it in .dev.vars locally, set it for production with `nerd secret set NAME` (the person pastes the value; never put a real value in a command you run), and read it from c.env.NAME on the server.";
|
|
2
|
+
const SECRET_EXAMPLE = { wrong: 'const stripe = new Stripe("sk_live_...");', right: 'const stripe = new Stripe(c.env.STRIPE_SECRET_KEY);' };
|
|
3
|
+
export const RULES = {
|
|
4
|
+
"secrets/aws-access-key": { title: "AWS access key in source", severity: "block", audience: "agent", risk: "Anyone who can read your code can use your AWS account and run up charges.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
5
|
+
"secrets/github-token": { title: "GitHub token in source", severity: "block", audience: "agent", risk: "The token gives access to your GitHub repositories and could be used to push malicious code.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
6
|
+
"secrets/github-pat": { title: "GitHub personal access token in source", severity: "block", audience: "agent", risk: "The token gives access to your GitHub account.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
7
|
+
"secrets/stripe-secret": { title: "Stripe secret key in source", severity: "block", audience: "agent", risk: "Your Stripe secret key can move money and read every customer's payment details.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
8
|
+
"secrets/stripe-webhook": { title: "Stripe webhook secret in source", severity: "block", audience: "agent", risk: "With the webhook secret, anyone can forge payment events and mark orders as paid.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
9
|
+
"secrets/slack-token": { title: "Slack token in source", severity: "block", audience: "agent", risk: "The token can read and post in your Slack workspace.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
10
|
+
"secrets/google-api-key": { title: "Google API key in source", severity: "block", audience: "agent", risk: "The key can be used to run up charges on your Google Cloud account.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
11
|
+
"secrets/openai-key": { title: "OpenAI API key in source", severity: "block", audience: "agent", risk: "Anyone with the key can make API calls billed to your account.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
12
|
+
"secrets/anthropic-key": { title: "Anthropic API key in source", severity: "block", audience: "agent", risk: "Anyone with the key can make API calls billed to your account.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
13
|
+
"secrets/cloudflare-token": { title: "Cloudflare API token in source", severity: "block", audience: "agent", risk: "The token can change your DNS, Workers, and storage.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
14
|
+
"secrets/sendgrid-key": { title: "SendGrid API key in source", severity: "block", audience: "agent", risk: "The key can send email as you.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
15
|
+
"secrets/private-key": { title: "Private key in source", severity: "block", audience: "agent", risk: "A private key in the repository lets anyone impersonate the service it belongs to.", fix: "Delete the key file from the repository and keep keys in secrets. If it was ever committed, generate a new key.", example: { wrong: "-----BEGIN RSA PRIVATE KEY----- in a committed file", right: "The key lives in a platform secret and is read at runtime" } },
|
|
16
|
+
"secrets/jwt": { title: "Signed token in source", severity: "block", audience: "agent", risk: "A token in the code may still be valid and grant access to whatever issued it.", fix: "Remove it. Tokens are issued at runtime; nothing in source should carry one.", example: SECRET_EXAMPLE },
|
|
17
|
+
"secrets/nerdsystem-token": { title: "nerdsystem token in source", severity: "block", audience: "agent", risk: "A platform token in the code lets anyone deploy or change this project.", fix: "Remove it and revoke it in the dashboard under API tokens. The CLI stores tokens outside the project.", example: SECRET_EXAMPLE },
|
|
18
|
+
"secrets/db-url-with-password": { title: "Database URL with password in source", severity: "block", audience: "agent", risk: "The password gives full access to that database.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
19
|
+
"secrets/generic-assignment": { title: "Hard-coded credential", severity: "block", audience: "agent", risk: "A value assigned to something named key, secret, token, or password ships to everyone who can read the code.", fix: SECRET_FIX, example: SECRET_EXAMPLE },
|
|
20
|
+
"secrets/env-file-committed": { title: "Environment file in the repository", severity: "block", audience: "agent", risk: "An .env file in git carries every secret in it to everyone with repository access.", fix: "Delete the file from the repository, add it to .gitignore, and keep local values in .dev.vars.", example: { wrong: ".env committed", right: ".env in .gitignore; values in .dev.vars and platform secrets" } },
|
|
21
|
+
"secrets/dev-vars-committed": { title: ".dev.vars in the repository", severity: "block", audience: "agent", risk: ".dev.vars holds the platform secrets for local development.", fix: "Remove it from the repository and keep it in .gitignore." },
|
|
22
|
+
"secrets/gitignore-dev-vars": { title: ".gitignore does not list .dev.vars", severity: "warn", audience: "agent", risk: "A future commit could include local secrets by accident.", fix: "Add a line `.dev.vars` to .gitignore." },
|
|
23
|
+
"secrets/rotate-committed-secret": { title: "A committed secret must be rotated", severity: "block", audience: "human", risk: "Removing a secret from the code does not remove it from git history; anyone who fetched the repository still has it.", fix: "Rotate the credential at the provider that issued it (create a new one, update the platform secret, revoke the old one). Then the agent can remove it from the code.", example: { wrong: "Delete the line and commit", right: "Rotate at the provider first, then delete the line" } },
|
|
24
|
+
"bundle/known-secret-in-client": { title: "A server secret is in the client bundle", severity: "block", audience: "agent", risk: "A value from .dev.vars is compiled into JavaScript that every visitor downloads.", fix: "Only VITE_-prefixed variables belong in the client, and none of them may be secrets. Move the call that needs the secret to a server route.", example: { wrong: "fetch(api, { headers: { Authorization: import.meta.env.VITE_API_SECRET } })", right: "app.post('/api/thing', async (c) => fetch(api, { headers: { Authorization: c.env.API_SECRET } }))" } },
|
|
25
|
+
"bundle/known-secret-in-server": { title: "A local secret value is in the server bundle", severity: "block", audience: "agent", risk: "A value that should come from platform secrets is hard-coded in the built server.", fix: "Read it from c.env instead of embedding it." },
|
|
26
|
+
"bundle/vite-secret-exposed": { title: "A VITE_ variable looks like a secret", severity: "block", audience: "agent", risk: "VITE_ variables are public: this one is compiled into the client where anyone can read it.", fix: "Rename it without the VITE_ prefix, set it as a server secret, and use it only in server code.", example: { wrong: "VITE_STRIPE_SECRET_KEY=sk_live_...", right: "STRIPE_SECRET_KEY as a platform secret, read on the server" } },
|
|
27
|
+
"deps/lockfile-required": { title: "No lockfile", severity: "block", audience: "agent", risk: "Without a lockfile every install can pull different versions, so what passed checks is not what deploys.", fix: "Run `pnpm install` and commit pnpm-lock.yaml." },
|
|
28
|
+
"deps/critical-vulnerability": { title: "Dependency with a critical vulnerability", severity: "block", audience: "agent", risk: "A known, exploitable bug in a package your app ships.", fix: "Update the package to a fixed version (`pnpm update <name>`), or replace it if no fix exists. Dependabot pull requests do this automatically.", example: { wrong: '"lodash": "4.17.15"', right: '"lodash": "^4.17.21"' } },
|
|
29
|
+
"deps/high-vulnerability": { title: "Dependency with a high-severity advisory", severity: "warn", audience: "agent", risk: "A known bug in a package your app ships; exploitable in some setups.", fix: "Update the package when a fixed version exists." },
|
|
30
|
+
"deps/advisory": { title: "Dependency advisory", severity: "info", audience: "agent", risk: "A known issue of lower severity.", fix: "Update when convenient." },
|
|
31
|
+
"deps/audit-unavailable": { title: "Dependency audit could not run", severity: "warn", audience: "human", risk: "Vulnerable packages would not be detected this run.", fix: "Check network access and the package manager, then run `nerd verify` again." },
|
|
32
|
+
"rules/platform": { title: "Platform rule", severity: "block", audience: "agent", risk: "The app would not run correctly on the platform.", fix: "Follow the instruction in the finding; the rules are listed in the nerdsystem skill." },
|
|
33
|
+
"rules/advisory": { title: "Platform advisory", severity: "warn", audience: "agent", risk: "Not blocking, but the project is missing something the platform expects.", fix: "Follow the instruction in the finding." },
|
|
34
|
+
"typecheck/error": { title: "TypeScript error", severity: "block", audience: "agent", risk: "The code would not compile reliably; the build may fail or behave unexpectedly.", fix: "Fix the type error at the file and line shown. Run `npx tsc -b` for the full message." },
|
|
35
|
+
"typecheck/failed": { title: "Typecheck failed", severity: "block", audience: "agent", risk: "The project does not compile.", fix: "Run `npx tsc -b` and fix the errors it prints." },
|
|
36
|
+
"policy/blocked-package": { title: "Package not allowed", severity: "block", audience: "agent", risk: "The package is a miner, proxy, or headless browser, which the acceptable use policy forbids and the platform cannot run.", fix: "Remove the package and the code that uses it. If the app genuinely needs a browser or scraping, say so to the person; it is not supported here." },
|
|
37
|
+
"policy/mining-pool": { title: "Mining pool reference", severity: "block", audience: "human", risk: "The code references a cryptocurrency mining pool, which is forbidden by the acceptable use policy.", fix: "Remove it. If this is unexpected, inspect the file: it may have been added by a compromised dependency." },
|
|
38
|
+
"policy/worker-size": { title: "Server bundle too large", severity: "block", audience: "agent", risk: "The server bundle exceeds the platform limit and cannot be deployed.", fix: "Remove large dependencies from server code, or load big data from storage at runtime instead of bundling it." },
|
|
39
|
+
"policy/assets-size": { title: "Static assets too large", severity: "block", audience: "agent", risk: "The client assets exceed the platform limit.", fix: "Move large files to the app's storage bucket and serve them from there." },
|
|
40
|
+
"pii/log-credentials": { title: "Logging something that looks like a credential", severity: "warn", audience: "agent", risk: "Passwords, tokens, or cookies written to logs are visible to anyone who can read logs.", fix: "Log an identifier instead of the value.", example: { wrong: 'console.log("login", body.password)', right: 'console.log("login attempt", user.id)' } },
|
|
41
|
+
"pii/log-request-body": { title: "Logging a request body", severity: "warn", audience: "agent", risk: "Request bodies often carry personal data; logging them keeps copies you did not mean to keep.", fix: "Log only the fields you need, never the whole body." },
|
|
42
|
+
"pii/email-in-query-string": { title: "Email address in a URL", severity: "warn", audience: "agent", risk: "URLs end up in browser history, logs, and referrer headers.", fix: "Send it in the request body or use an id." },
|
|
43
|
+
"pii/plaintext-password-column": { title: "A password column in the schema", severity: "warn", audience: "agent", risk: "Storing passwords yourself is unnecessary here and easy to get wrong.", fix: "Remove the column; the platform handles passwords. If you must store a secret, store a hash." },
|
|
44
|
+
"pii/unscoped-user-table": { title: "Query on user data without a user filter", severity: "warn", audience: "agent", risk: "One user could see or change another user's rows.", fix: "Add .where(eq(table.userId, c.var.user.id)) to every read and write on tables with a user_id column.", example: { wrong: "db.select().from(schema.notes).all()", right: "db.select().from(schema.notes).where(eq(schema.notes.userId, c.var.user!.id)).all()" } },
|
|
45
|
+
"pii/wildcard-cors": { title: "Wildcard CORS with cookies", severity: "warn", audience: "agent", risk: "Any website could call your API with the visitor's cookies.", fix: "Remove the wildcard. Same-origin requests need no CORS headers." },
|
|
46
|
+
"suppressions/ignore-comment": { title: "A scanner ignore comment was added", severity: "block", audience: "human", risk: "Ignore comments turn off a check for that line. They are sometimes right and sometimes a way to hide a problem.", fix: "Review the line in the dashboard and approve the suppression if it is correct; the agent must not add or approve suppressions itself." },
|
|
47
|
+
"suppressions/ignore-file": { title: "A scanner ignore file was changed", severity: "block", audience: "human", risk: "Ignore files silence checks across the whole project.", fix: "Review the change in the dashboard and approve it if it is correct." },
|
|
48
|
+
"suppressions/gate-config": { title: "Gate configuration was changed", severity: "block", audience: "human", risk: "Changing gate settings can disable checks for every future deploy.", fix: "Review the change in the dashboard and approve it if it is correct." },
|
|
49
|
+
"gates/retry-cap": { title: "Automatic fixing stopped after three attempts", severity: "block", audience: "human", risk: "The same rule has failed three runs in a row; further automatic attempts are unlikely to help and may make things worse.", fix: "Read the findings above and decide: fix it by hand, ask the agent a different way, or approve a suppression in the dashboard if the finding is wrong." },
|
|
50
|
+
"smoke/unreachable": { title: "Deployed app did not respond", severity: "block", audience: "human", risk: "The app is not serving requests.", fix: "Check `nerd logs` for startup errors and the deployment status." },
|
|
51
|
+
"smoke/header-missing": { title: "A security header is missing", severity: "warn", audience: "agent", risk: "Browsers lose a protection against clickjacking, content sniffing, or script injection.", fix: "Keep createNerdApp()'s default security headers; if you disabled them, re-enable or set the header yourself." },
|
|
52
|
+
"smoke/auth-me": { title: "Session endpoint misbehaves", severity: "block", audience: "agent", risk: "Visitors cannot tell whether they are signed in, so the app's login flow is broken.", fix: "/api/auth/me must return 200 with {user:null} when signed out. Do not override the SDK's auth routes." },
|
|
53
|
+
"smoke/auth-login": { title: "Wrong credentials are not rejected", severity: "block", audience: "agent", risk: "The login endpoint does not return 401 for a wrong password.", fix: "Do not override the SDK's /api/auth/login route." },
|
|
54
|
+
};
|
|
55
|
+
/** Fallback for scanner rule ids not in the catalog (semgrep and gitleaks have their own). */
|
|
56
|
+
export function ruleFor(id, fallback = {}) {
|
|
57
|
+
return RULES[id] ?? { title: fallback.title ?? id, severity: fallback.severity ?? "warn", audience: fallback.audience ?? "agent", risk: fallback.risk ?? "A scanner flagged this pattern.", fix: fallback.fix ?? "Review the line and change the pattern; see the scanner's documentation for the rule." };
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Finding, type Gate } from "./types.js";
|
|
2
|
+
export { mask } from "./text.js";
|
|
3
|
+
export declare function fingerprint(...parts: (string | number | undefined)[]): string;
|
|
4
|
+
export declare function finding(ruleId: string, extra: Partial<Finding> & {
|
|
5
|
+
file?: string | undefined;
|
|
6
|
+
line?: number | undefined;
|
|
7
|
+
}): Finding;
|
|
8
|
+
export declare function scanText(text: string, file: string): Finding[];
|
|
9
|
+
export declare const secretsGate: Gate;
|
|
10
|
+
/** Built output must not carry server secrets or anything that looks like one. */
|
|
11
|
+
export declare const bundleGate: Gate;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { exec } from "./code.js";
|
|
5
|
+
import { sourceFiles } from "./fs.js";
|
|
6
|
+
import { findSecrets, mask } from "./text.js";
|
|
7
|
+
import { RULES } from "./rules.js";
|
|
8
|
+
import { counts, result, verdict } from "./types.js";
|
|
9
|
+
export { mask } from "./text.js";
|
|
10
|
+
export function fingerprint(...parts) {
|
|
11
|
+
return createHash("sha256").update(parts.map((p) => String(p ?? "")).join("\u0000")).digest("hex").slice(0, 16);
|
|
12
|
+
}
|
|
13
|
+
export function finding(ruleId, extra) {
|
|
14
|
+
const def = RULES[ruleId];
|
|
15
|
+
if (!def)
|
|
16
|
+
throw new Error(`unknown rule ${ruleId}`);
|
|
17
|
+
return {
|
|
18
|
+
rule_id: ruleId,
|
|
19
|
+
severity: def.severity,
|
|
20
|
+
audience: def.audience,
|
|
21
|
+
risk: def.risk,
|
|
22
|
+
fix: def.fix,
|
|
23
|
+
verify: `nerd verify --rule ${ruleId}`,
|
|
24
|
+
attempt: 1,
|
|
25
|
+
...extra,
|
|
26
|
+
fingerprint: extra.fingerprint ?? fingerprint(ruleId, extra.file, extra.line),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function scanText(text, file) {
|
|
30
|
+
return findSecrets(text, file).map((h) => finding(`secrets/${h.rule}`, { file: h.file, line: h.line, masked: h.masked }));
|
|
31
|
+
}
|
|
32
|
+
async function isTracked(root, file) {
|
|
33
|
+
if (!existsSync(path.join(root, ".git")))
|
|
34
|
+
return false;
|
|
35
|
+
const r = await exec("git", ["ls-files", "--error-unmatch", "--", file], root);
|
|
36
|
+
return r.code === 0;
|
|
37
|
+
}
|
|
38
|
+
export const secretsGate = {
|
|
39
|
+
name: "secrets",
|
|
40
|
+
title: "No secrets in source",
|
|
41
|
+
blocking: true,
|
|
42
|
+
rulePrefixes: ["secrets/"],
|
|
43
|
+
async run(ctx) {
|
|
44
|
+
const t = Date.now();
|
|
45
|
+
const findings = [];
|
|
46
|
+
for (const f of sourceFiles(ctx.root)) {
|
|
47
|
+
if (/(^|\/)\.env(\.|$)/.test(f.rel) && !f.rel.endsWith(".example") && !f.rel.endsWith(".dev.vars.example"))
|
|
48
|
+
findings.push(finding("secrets/env-file-committed", { file: f.rel }));
|
|
49
|
+
if (/(^|\/)\.dev\.vars$/.test(f.rel))
|
|
50
|
+
findings.push(finding("secrets/dev-vars-committed", { file: f.rel }));
|
|
51
|
+
if (f.rel.endsWith(".example"))
|
|
52
|
+
continue;
|
|
53
|
+
findings.push(...scanText(f.text, f.rel));
|
|
54
|
+
}
|
|
55
|
+
const gitignore = path.join(ctx.root, ".gitignore");
|
|
56
|
+
if (!existsSync(gitignore) || !/\.dev\.vars/.test(readFileSync(gitignore, "utf8")))
|
|
57
|
+
findings.push(finding("secrets/gitignore-dev-vars", { file: ".gitignore" }));
|
|
58
|
+
// A secret in a committed file is already in history: removing it is not enough, a person must rotate it.
|
|
59
|
+
const credentialFiles = Array.from(new Set(findings.filter((f) => f.file && f.masked).map((f) => f.file)));
|
|
60
|
+
for (const file of credentialFiles) {
|
|
61
|
+
if (ctx.committed || (await isTracked(ctx.root, file))) {
|
|
62
|
+
const first = findings.find((f) => f.file === file && f.masked);
|
|
63
|
+
findings.push(finding("secrets/rotate-committed-secret", { file, line: first.line, masked: first.masked }));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return result(this, verdict(findings), findings.length ? counts(findings) : "No credentials found in source", findings, t);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
/** Built output must not carry server secrets or anything that looks like one. */
|
|
70
|
+
export const bundleGate = {
|
|
71
|
+
name: "bundle",
|
|
72
|
+
title: "No secrets in the built bundle",
|
|
73
|
+
blocking: true,
|
|
74
|
+
rulePrefixes: ["bundle/", "secrets/"],
|
|
75
|
+
async run(ctx) {
|
|
76
|
+
const t = Date.now();
|
|
77
|
+
if (!ctx.build)
|
|
78
|
+
return result(this, "skip", "No build output to scan", [], t);
|
|
79
|
+
const findings = [];
|
|
80
|
+
const scan = (rel, text, isClient) => {
|
|
81
|
+
findings.push(...scanText(text, rel));
|
|
82
|
+
for (const secret of ctx.knownSecrets ?? []) {
|
|
83
|
+
if (secret.length >= 12 && text.includes(secret))
|
|
84
|
+
findings.push(finding(isClient ? "bundle/known-secret-in-client" : "bundle/known-secret-in-server", { file: rel, masked: mask(secret) }));
|
|
85
|
+
}
|
|
86
|
+
if (isClient && /VITE_[A-Z0-9_]*(SECRET|PRIVATE|PASSWORD|TOKEN|API_KEY)[A-Z0-9_]*/.test(text))
|
|
87
|
+
findings.push(finding("bundle/vite-secret-exposed", { file: rel }));
|
|
88
|
+
};
|
|
89
|
+
for (const m of ctx.build.modules)
|
|
90
|
+
if (/\.(js|mjs)$/.test(m))
|
|
91
|
+
scan(`dist/worker/${m}`, readFileSync(path.join(ctx.build.workerDir, m), "utf8"), false);
|
|
92
|
+
if (ctx.build.assetsDir)
|
|
93
|
+
for (const a of ctx.build.assets)
|
|
94
|
+
if (/\.(js|mjs|html|css|json|map)$/.test(a))
|
|
95
|
+
scan(`dist/client/${a}`, readFileSync(path.join(ctx.build.assetsDir, a), "utf8"), true);
|
|
96
|
+
return result(this, verdict(findings), findings.length ? counts(findings) : "Built output is clean", findings, t);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { lineOf, sourceFiles } from "./fs.js";
|
|
4
|
+
import { fingerprint, finding } from "./secrets.js";
|
|
5
|
+
import { counts, result, verdict } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Suppressions need a human. Any ignore comment, ignore file, or gate configuration in the tree is
|
|
8
|
+
* reported with audience "human" and keeps being reported on every run until its fingerprint is
|
|
9
|
+
* approved in the dashboard. The agent is never the one who decides a check does not apply.
|
|
10
|
+
*/
|
|
11
|
+
const IGNORE_COMMENT = /(nerd-ignore|nosemgrep|semgrep-ignore|gitleaks:allow|nerd-allow)\b/g;
|
|
12
|
+
const IGNORE_FILES = [".gitleaksignore", ".semgrepignore", ".nerdignore", ".gitleaks.toml", ".semgrep.yml", ".semgrep.yaml"];
|
|
13
|
+
export const suppressionsGate = {
|
|
14
|
+
name: "suppressions",
|
|
15
|
+
title: "Scanner suppressions",
|
|
16
|
+
blocking: true,
|
|
17
|
+
rulePrefixes: ["suppressions/"],
|
|
18
|
+
async run(ctx) {
|
|
19
|
+
const t = Date.now();
|
|
20
|
+
const findings = [];
|
|
21
|
+
const approved = ctx.approvedSuppressions ?? new Set();
|
|
22
|
+
const add = (f) => {
|
|
23
|
+
if (approved.has(f.fingerprint))
|
|
24
|
+
findings.push({ ...f, severity: "info", audience: "agent", risk: `${f.risk} This one was approved in the dashboard.`, fix: "No action." });
|
|
25
|
+
else
|
|
26
|
+
findings.push(f);
|
|
27
|
+
};
|
|
28
|
+
for (const f of sourceFiles(ctx.root)) {
|
|
29
|
+
if (!/\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs)$/.test(f.rel))
|
|
30
|
+
continue;
|
|
31
|
+
IGNORE_COMMENT.lastIndex = 0;
|
|
32
|
+
let m;
|
|
33
|
+
while ((m = IGNORE_COMMENT.exec(f.text))) {
|
|
34
|
+
const line = lineOf(f.text, m.index);
|
|
35
|
+
const lineText = f.text.split("\n")[line - 1] ?? "";
|
|
36
|
+
add(finding("suppressions/ignore-comment", { file: f.rel, line, fingerprint: fingerprint("ignore-comment", f.rel, lineText.trim()) }));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const name of IGNORE_FILES) {
|
|
40
|
+
const p = path.join(ctx.root, name);
|
|
41
|
+
if (existsSync(p))
|
|
42
|
+
add(finding("suppressions/ignore-file", { file: name, fingerprint: fingerprint("ignore-file", name, readFileSync(p, "utf8")) }));
|
|
43
|
+
}
|
|
44
|
+
const nerdJson = path.join(ctx.root, "nerd.json");
|
|
45
|
+
if (existsSync(nerdJson)) {
|
|
46
|
+
try {
|
|
47
|
+
const cfg = JSON.parse(readFileSync(nerdJson, "utf8"));
|
|
48
|
+
if (cfg.gates !== undefined)
|
|
49
|
+
add(finding("suppressions/gate-config", { file: "nerd.json", fingerprint: fingerprint("gate-config", JSON.stringify(cfg.gates)) }));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// nerd.json problems are reported by the rules gate
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result(this, verdict(findings), findings.length ? counts(findings) : "No suppressions", findings, t);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of the gates that work on text alone: no filesystem, no child processes, no Node
|
|
3
|
+
* imports. The CLI, the builder, and the control plane's public repo scanner all use these, so a
|
|
4
|
+
* pattern added here is caught everywhere.
|
|
5
|
+
*/
|
|
6
|
+
/** Known credential formats. Each must be specific enough to avoid matching ordinary code. */
|
|
7
|
+
export declare const SECRET_PATTERNS: {
|
|
8
|
+
rule: string;
|
|
9
|
+
re: RegExp;
|
|
10
|
+
}[];
|
|
11
|
+
export declare function isAllowedLine(line: string): boolean;
|
|
12
|
+
/** Type and last four characters only: "sk_live_****4f2a". Never the value. */
|
|
13
|
+
export declare function mask(match: string): string;
|
|
14
|
+
export declare function lineAt(text: string, index: number): number;
|
|
15
|
+
export interface SecretHit {
|
|
16
|
+
/** Rule id without the "secrets/" prefix. */
|
|
17
|
+
rule: string;
|
|
18
|
+
file: string;
|
|
19
|
+
line: number;
|
|
20
|
+
masked: string;
|
|
21
|
+
}
|
|
22
|
+
/** Every credential-shaped value in a text, one hit per line, masked. */
|
|
23
|
+
export declare function findSecrets(text: string, file: string, limit?: number): SecretHit[];
|
|
24
|
+
/** Packages that exist to mine, proxy, or drive browsers from a customer Worker, and known pool hosts. */
|
|
25
|
+
export declare const BLOCKED_PACKAGES: Set<string>;
|
|
26
|
+
export declare const MINING_POOLS: RegExp;
|
|
27
|
+
/** Files that must never be committed. */
|
|
28
|
+
export declare const ENV_FILES: RegExp;
|
|
29
|
+
/** Source files worth scanning for text patterns; everything else is skipped as binary or generated. */
|
|
30
|
+
export declare const TEXT_FILE: RegExp;
|
|
31
|
+
export declare const SKIP_DIR: RegExp;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of the gates that work on text alone: no filesystem, no child processes, no Node
|
|
3
|
+
* imports. The CLI, the builder, and the control plane's public repo scanner all use these, so a
|
|
4
|
+
* pattern added here is caught everywhere.
|
|
5
|
+
*/
|
|
6
|
+
/** Known credential formats. Each must be specific enough to avoid matching ordinary code. */
|
|
7
|
+
export const SECRET_PATTERNS = [
|
|
8
|
+
{ rule: "aws-access-key", re: /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g },
|
|
9
|
+
{ rule: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
|
|
10
|
+
{ rule: "github-pat", re: /\bgithub_pat_[A-Za-z0-9_]{80,}\b/g },
|
|
11
|
+
{ rule: "stripe-secret", re: /\b(sk|rk)_(live|test)_[A-Za-z0-9]{20,}\b/g },
|
|
12
|
+
{ rule: "stripe-webhook", re: /\bwhsec_[A-Za-z0-9]{20,}\b/g },
|
|
13
|
+
{ rule: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
14
|
+
{ rule: "google-api-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
15
|
+
{ rule: "openai-key", re: /\bsk-(proj-)?[A-Za-z0-9_-]{32,}\b/g },
|
|
16
|
+
{ rule: "anthropic-key", re: /\bsk-ant-[A-Za-z0-9_-]{32,}\b/g },
|
|
17
|
+
{ rule: "cloudflare-token", re: /\bv1\.0-[a-f0-9]{24}-[a-f0-9]{144}\b/g },
|
|
18
|
+
{ rule: "sendgrid-key", re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g },
|
|
19
|
+
{ rule: "private-key", re: /-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY( BLOCK)?-----/g },
|
|
20
|
+
{ rule: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
|
|
21
|
+
{ rule: "nerdsystem-token", re: /\bn(sk|as|bt|rt|ss|pr|iv)_[a-z0-9]{40}\b/g },
|
|
22
|
+
{ rule: "db-url-with-password", re: /\b(postgres|postgresql|mysql|mongodb(\+srv)?|redis):\/\/[^:\s/]+:[^@\s/]{4,}@/g },
|
|
23
|
+
{ rule: "generic-assignment", re: /\b(api[_-]?key|secret|password|passwd|token|client[_-]?secret)\b\s*[:=]\s*["'`]([A-Za-z0-9+/_=.-]{24,})["'`]/gi },
|
|
24
|
+
];
|
|
25
|
+
const ALLOW = [/example/i, /placeholder/i, /your[-_ ]?key/i, /xxxx/i, /\$\{/, /process\.env/, /c\.env\./, /import\.meta\.env/, /nsk_\.\.\./];
|
|
26
|
+
export function isAllowedLine(line) {
|
|
27
|
+
return ALLOW.some((a) => a.test(line));
|
|
28
|
+
}
|
|
29
|
+
/** Type and last four characters only: "sk_live_****4f2a". Never the value. */
|
|
30
|
+
export function mask(match) {
|
|
31
|
+
const compact = match.replace(/\s+/g, "");
|
|
32
|
+
const prefix = /^[A-Za-z0-9]+[_-]/.exec(compact)?.[0] ?? compact.slice(0, 4);
|
|
33
|
+
return `${prefix}****${compact.slice(-4)}`;
|
|
34
|
+
}
|
|
35
|
+
export function lineAt(text, index) {
|
|
36
|
+
let line = 1;
|
|
37
|
+
for (let i = 0; i < index && i < text.length; i++)
|
|
38
|
+
if (text.charCodeAt(i) === 10)
|
|
39
|
+
line++;
|
|
40
|
+
return line;
|
|
41
|
+
}
|
|
42
|
+
/** Every credential-shaped value in a text, one hit per line, masked. */
|
|
43
|
+
export function findSecrets(text, file, limit = 50) {
|
|
44
|
+
const hits = [];
|
|
45
|
+
const hitLines = new Set();
|
|
46
|
+
const lines = text.split("\n");
|
|
47
|
+
for (const p of SECRET_PATTERNS) {
|
|
48
|
+
p.re.lastIndex = 0;
|
|
49
|
+
let m;
|
|
50
|
+
while ((m = p.re.exec(text))) {
|
|
51
|
+
const line = lineAt(text, m.index);
|
|
52
|
+
if (hitLines.has(line))
|
|
53
|
+
continue;
|
|
54
|
+
if (isAllowedLine(lines[line - 1] ?? ""))
|
|
55
|
+
continue;
|
|
56
|
+
hitLines.add(line);
|
|
57
|
+
const matched = p.rule === "generic-assignment" ? (m[2] ?? m[0]) : m[0];
|
|
58
|
+
hits.push({ rule: p.rule, file, line, masked: p.rule === "private-key" ? "private key" : mask(matched) });
|
|
59
|
+
if (hits.length >= limit)
|
|
60
|
+
return hits;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return hits;
|
|
64
|
+
}
|
|
65
|
+
/** Packages that exist to mine, proxy, or drive browsers from a customer Worker, and known pool hosts. */
|
|
66
|
+
export const BLOCKED_PACKAGES = new Set(["coin-hive", "coinhive", "cryptonight-wasm", "xmrig", "webminer", "tor-request", "proxy-chain", "http-mitm-proxy", "puppeteer", "playwright"]);
|
|
67
|
+
export const MINING_POOLS = /(stratum\+tcp:\/\/|pool\.minexmr|supportxmr\.com|nanopool\.org|2miners\.com)/i;
|
|
68
|
+
/** Files that must never be committed. */
|
|
69
|
+
export const ENV_FILES = /(^|\/)(\.env(\.[A-Za-z0-9_-]+)?|\.dev\.vars(\.[A-Za-z0-9_-]+)?)$/;
|
|
70
|
+
/** Source files worth scanning for text patterns; everything else is skipped as binary or generated. */
|
|
71
|
+
export const TEXT_FILE = /(\.(ts|tsx|js|jsx|mjs|cjs|json|jsonc|toml|yaml|yml|env|vars|md|html|css|sql|py|rb|go|rs|java|kt|swift|php|sh|txt|cfg|ini|conf|xml)|(^|\/)(README|LICENSE|Dockerfile|Makefile|Procfile|\.gitignore|\.npmrc))$/i;
|
|
72
|
+
export const SKIP_DIR = /(^|\/)(node_modules|dist|build|\.git|\.wrangler|\.next|\.nuxt|coverage|vendor|target)(\/|$)/;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Gate } from "./types.js";
|
|
2
|
+
/** gitleaks over the working tree. Complements the built-in scanner with its larger rule set. */
|
|
3
|
+
export declare const gitleaksGate: Gate;
|
|
4
|
+
/** Semgrep with the nerdsystem rule set. ERROR rules block, WARNING rules advise. */
|
|
5
|
+
export declare const semgrepGate: Gate;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { exec, hasTool } from "./code.js";
|
|
5
|
+
import { ruleFor } from "./rules.js";
|
|
6
|
+
import { fingerprint, mask } from "./secrets.js";
|
|
7
|
+
import { counts, result, verdict } from "./types.js";
|
|
8
|
+
/** gitleaks over the working tree. Complements the built-in scanner with its larger rule set. */
|
|
9
|
+
export const gitleaksGate = {
|
|
10
|
+
name: "gitleaks",
|
|
11
|
+
title: "Secret scan (gitleaks)",
|
|
12
|
+
blocking: true,
|
|
13
|
+
rulePrefixes: ["gitleaks/"],
|
|
14
|
+
async run(ctx) {
|
|
15
|
+
const t = Date.now();
|
|
16
|
+
if (!(await hasTool("gitleaks")))
|
|
17
|
+
return result(this, "skip", "gitleaks not installed; built-in scan still ran", [], t, "gitleaks");
|
|
18
|
+
const report = path.join(ctx.root, ".nerd-gitleaks.json");
|
|
19
|
+
const r = await exec("gitleaks", ["dir", ctx.root, "--no-banner", "--exit-code", "2", "--report-format", "json", "--report-path", report], ctx.root);
|
|
20
|
+
const findings = [];
|
|
21
|
+
try {
|
|
22
|
+
if (existsSync(report)) {
|
|
23
|
+
const json = JSON.parse(readFileSync(report, "utf8"));
|
|
24
|
+
for (const f of json.slice(0, 50)) {
|
|
25
|
+
const rule_id = `gitleaks/${f.RuleID}`;
|
|
26
|
+
const def = ruleFor(rule_id, { title: f.RuleID, severity: "block", risk: "A credential of this type is in the source. Anyone with read access to the code can use it.", fix: "Remove the value from the code and read it from a platform secret. If it was committed, rotate it at the provider." });
|
|
27
|
+
const file = path.relative(ctx.root, f.File).split(path.sep).join("/");
|
|
28
|
+
findings.push({ rule_id, severity: def.severity, audience: def.audience, file, line: f.StartLine, risk: def.risk, fix: def.fix, verify: `nerd verify --rule ${rule_id}`, attempt: 1, masked: f.Secret ? mask(f.Secret) : undefined, fingerprint: fingerprint(rule_id, file, f.StartLine) });
|
|
29
|
+
}
|
|
30
|
+
unlinkSync(report);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// no report means no findings
|
|
35
|
+
}
|
|
36
|
+
if (r.code !== 0 && r.code !== 2)
|
|
37
|
+
return result(this, "warn", "gitleaks could not run", [], t, "gitleaks");
|
|
38
|
+
return result(this, verdict(findings), findings.length ? counts(findings) : "No secrets found", findings, t, "gitleaks");
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
/** Semgrep with the nerdsystem rule set. ERROR rules block, WARNING rules advise. */
|
|
42
|
+
export const semgrepGate = {
|
|
43
|
+
name: "semgrep",
|
|
44
|
+
title: "Static analysis (semgrep)",
|
|
45
|
+
blocking: true,
|
|
46
|
+
rulePrefixes: ["semgrep/"],
|
|
47
|
+
async run(ctx) {
|
|
48
|
+
const t = Date.now();
|
|
49
|
+
if (!(await hasTool("semgrep")))
|
|
50
|
+
return result(this, "skip", "semgrep not installed", [], t, "semgrep");
|
|
51
|
+
const rules = process.env.NERD_SEMGREP_RULES ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../rules/semgrep.yml");
|
|
52
|
+
const r = await exec("semgrep", ["scan", "--config", rules, "--json", "--quiet", "--metrics", "off", "--exclude", "node_modules", "--exclude", "dist", "src"], ctx.root);
|
|
53
|
+
const findings = [];
|
|
54
|
+
try {
|
|
55
|
+
const json = JSON.parse(r.stdout);
|
|
56
|
+
for (const x of json.results.slice(0, 100)) {
|
|
57
|
+
const short = x.check_id.split(".").pop() ?? x.check_id;
|
|
58
|
+
const rule_id = `semgrep/${short}`;
|
|
59
|
+
// Our own rule messages are templates we wrote; they carry no repository text.
|
|
60
|
+
const def = ruleFor(rule_id, { title: short, severity: x.extra.severity === "ERROR" ? "block" : "warn", risk: x.extra.message, fix: "Change the pattern at the file and line shown; the rule message says what to use instead." });
|
|
61
|
+
findings.push({ rule_id, severity: def.severity, audience: def.audience, file: x.path, line: x.start.line, risk: def.risk, fix: def.fix, verify: `nerd verify --rule ${rule_id}`, attempt: 1, fingerprint: fingerprint(rule_id, x.path, x.start.line) });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return result(this, "warn", "semgrep could not run", [], t, "semgrep");
|
|
66
|
+
}
|
|
67
|
+
return result(this, verdict(findings), findings.length ? counts(findings) : "No findings", findings, t, "semgrep");
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One gate = one named check with a verdict and a list of failures the coding agent can act on.
|
|
3
|
+
* Every failure is written for a beginner: what could happen (risk), what to change (fix), and how
|
|
4
|
+
* to confirm it (verify). Text comes from rule templates, never from repository content, so a
|
|
5
|
+
* malicious repo cannot plant instructions. Secrets are reported masked.
|
|
6
|
+
*/
|
|
7
|
+
export type GateStatus = "pass" | "warn" | "fail" | "skip";
|
|
8
|
+
export type Severity = "block" | "warn" | "info";
|
|
9
|
+
export type Audience = "agent" | "human";
|
|
10
|
+
export interface Finding {
|
|
11
|
+
/** Namespaced rule id, e.g. secrets/aws-access-key. */
|
|
12
|
+
rule_id: string;
|
|
13
|
+
severity: Severity;
|
|
14
|
+
/** Who should act. "human" for things an agent must not or cannot do (rotating a leaked key, approving a suppression). */
|
|
15
|
+
audience: Audience;
|
|
16
|
+
file?: string | undefined;
|
|
17
|
+
line?: number | undefined;
|
|
18
|
+
/** What could happen if this ships. One or two sentences, plain words. */
|
|
19
|
+
risk: string;
|
|
20
|
+
/** What to change. Concrete, no repository strings. */
|
|
21
|
+
fix: string;
|
|
22
|
+
/** The command that confirms the fix. */
|
|
23
|
+
verify: string;
|
|
24
|
+
/** How many consecutive runs this rule has failed, maintained by the runner. */
|
|
25
|
+
attempt: number;
|
|
26
|
+
/** For credentials: type and last four characters only, e.g. "sk_live_****4f2a". */
|
|
27
|
+
masked?: string | undefined;
|
|
28
|
+
/** Stable id for suppression approvals. */
|
|
29
|
+
fingerprint?: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
export interface GateResult {
|
|
32
|
+
name: string;
|
|
33
|
+
title: string;
|
|
34
|
+
status: GateStatus;
|
|
35
|
+
summary: string;
|
|
36
|
+
findings: Finding[];
|
|
37
|
+
durationMs: number;
|
|
38
|
+
/** Which tool produced it: "builtin" or an external scanner name. */
|
|
39
|
+
tool: string;
|
|
40
|
+
}
|
|
41
|
+
export interface GateContext {
|
|
42
|
+
root: string;
|
|
43
|
+
/** Built output, when the build gate has run. */
|
|
44
|
+
build?: {
|
|
45
|
+
workerDir: string;
|
|
46
|
+
modules: string[];
|
|
47
|
+
assetsDir: string | undefined;
|
|
48
|
+
assets: string[];
|
|
49
|
+
} | undefined;
|
|
50
|
+
/** Secret values known to be server-side (from .dev.vars) so their presence in client assets can be flagged. */
|
|
51
|
+
knownSecrets?: string[] | undefined;
|
|
52
|
+
/** Fingerprints of suppressions a human approved in the dashboard. */
|
|
53
|
+
approvedSuppressions?: Set<string> | undefined;
|
|
54
|
+
/** Run only rules whose id starts with one of these (e.g. ["secrets/"]); empty = all. */
|
|
55
|
+
onlyRules?: string[] | undefined;
|
|
56
|
+
/** Whether files in this tree are committed to git (builds) or may be uncommitted (local). */
|
|
57
|
+
committed?: boolean | undefined;
|
|
58
|
+
log: (line: string) => void;
|
|
59
|
+
}
|
|
60
|
+
export interface Gate {
|
|
61
|
+
name: string;
|
|
62
|
+
title: string;
|
|
63
|
+
/** Gates that fail stop the pipeline; warn-only gates never block. */
|
|
64
|
+
blocking: boolean;
|
|
65
|
+
/** Rule id prefixes this gate can emit; used by onlyRules. */
|
|
66
|
+
rulePrefixes: string[];
|
|
67
|
+
run(ctx: GateContext): Promise<GateResult>;
|
|
68
|
+
}
|
|
69
|
+
export declare function result(gate: Gate, status: GateStatus, summary: string, findings: Finding[], startedAt: number, tool?: string): GateResult;
|
|
70
|
+
/** Status from findings: any block = fail, any warn = warn, else pass. */
|
|
71
|
+
export declare function verdict(findings: Finding[]): GateStatus;
|
|
72
|
+
export declare function counts(findings: Finding[]): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function result(gate, status, summary, findings, startedAt, tool = "builtin") {
|
|
2
|
+
return { name: gate.name, title: gate.title, status, summary, findings, durationMs: Date.now() - startedAt, tool };
|
|
3
|
+
}
|
|
4
|
+
/** Status from findings: any block = fail, any warn = warn, else pass. */
|
|
5
|
+
export function verdict(findings) {
|
|
6
|
+
if (findings.some((f) => f.severity === "block"))
|
|
7
|
+
return "fail";
|
|
8
|
+
if (findings.some((f) => f.severity === "warn"))
|
|
9
|
+
return "warn";
|
|
10
|
+
return "pass";
|
|
11
|
+
}
|
|
12
|
+
export function counts(findings) {
|
|
13
|
+
const b = findings.filter((f) => f.severity === "block").length;
|
|
14
|
+
const w = findings.filter((f) => f.severity === "warn").length;
|
|
15
|
+
if (!findings.length)
|
|
16
|
+
return "No findings";
|
|
17
|
+
return [b ? `${b} blocking` : "", w ? `${w} advisory` : "", findings.length - b - w ? `${findings.length - b - w} informational` : ""].filter(Boolean).join(", ");
|
|
18
|
+
}
|
package/dist/index.d.ts
ADDED