ship-safe 4.2.0 → 4.3.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.
@@ -1,253 +1,262 @@
1
- /**
2
- * BaseAgent — Foundation for all security scanning agents
3
- * ========================================================
4
- *
5
- * Every agent in ship-safe extends BaseAgent. It provides:
6
- * - Standard finding format
7
- * - File discovery with skip-list support
8
- * - Severity classification
9
- * - Consistent output interface
10
- *
11
- * USAGE:
12
- * class MyAgent extends BaseAgent {
13
- * constructor() { super('MyAgent', 'Description', 'category'); }
14
- * async analyze(context) { return [findings]; }
15
- * }
16
- */
17
-
18
- import fs from 'fs';
19
- import path from 'path';
20
- import fg from 'fast-glob';
21
- import { SKIP_DIRS, SKIP_EXTENSIONS, MAX_FILE_SIZE, loadGitignorePatterns } from '../utils/patterns.js';
22
-
23
- // =============================================================================
24
- // FINDING FACTORY
25
- // =============================================================================
26
-
27
- /**
28
- * Create a standardized finding object.
29
- */
30
- export function createFinding({
31
- file,
32
- line = 0,
33
- column = 0,
34
- severity = 'medium',
35
- category = 'vulnerability',
36
- rule,
37
- title,
38
- description,
39
- matched = '',
40
- confidence = 'high',
41
- cwe = null,
42
- owasp = null,
43
- fix = null,
44
- }) {
45
- return {
46
- file,
47
- line,
48
- column,
49
- severity,
50
- category,
51
- rule,
52
- title,
53
- description,
54
- matched,
55
- confidence,
56
- cwe,
57
- owasp,
58
- fix,
59
- };
60
- }
61
-
62
- // =============================================================================
63
- // BASE AGENT CLASS
64
- // =============================================================================
65
-
66
- export class BaseAgent {
67
- /**
68
- * @param {string} name — Agent name (e.g. 'InjectionTester')
69
- * @param {string} description — What this agent does
70
- * @param {string} category — Finding category for scoring
71
- */
72
- constructor(name, description, category) {
73
- this.name = name;
74
- this.description = description;
75
- this.category = category;
76
- }
77
-
78
- /**
79
- * Run the agent's analysis on a codebase.
80
- * Subclasses MUST override this method.
81
- *
82
- * @param {object} context — { rootPath, files, recon, options }
83
- * @returns {Promise<object[]>} — Array of finding objects
84
- */
85
- async analyze(context) {
86
- throw new Error(`${this.name}.analyze() not implemented`);
87
- }
88
-
89
- // ── Helpers available to all agents ─────────────────────────────────────────
90
-
91
- /**
92
- * Discover all scannable files in a directory.
93
- * Respects SKIP_DIRS, SKIP_EXTENSIONS, and MAX_FILE_SIZE.
94
- */
95
- async discoverFiles(rootPath, extraGlobs = ['**/*']) {
96
- const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
97
-
98
- // Respect .gitignore patterns
99
- const gitignoreGlobs = loadGitignorePatterns(rootPath);
100
- globIgnore.push(...gitignoreGlobs);
101
-
102
- // Load .ship-safeignore patterns
103
- const ignorePatterns = this._loadIgnorePatterns(rootPath);
104
- for (const p of ignorePatterns) {
105
- if (p.endsWith('/')) {
106
- globIgnore.push(`**/${p}**`);
107
- } else {
108
- globIgnore.push(`**/${p}`);
109
- globIgnore.push(p);
110
- }
111
- }
112
-
113
- const allFiles = await fg(extraGlobs, {
114
- cwd: rootPath,
115
- absolute: true,
116
- onlyFiles: true,
117
- ignore: globIgnore,
118
- dot: true,
119
- });
120
-
121
- return allFiles.filter(file => {
122
- const ext = path.extname(file).toLowerCase();
123
- if (SKIP_EXTENSIONS.has(ext)) return false;
124
- const basename = path.basename(file);
125
- if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) return false;
126
- try {
127
- const stats = fs.statSync(file);
128
- if (stats.size > MAX_FILE_SIZE) return false;
129
- } catch {
130
- return false;
131
- }
132
- return true;
133
- });
134
- }
135
-
136
- /**
137
- * Load .ship-safeignore patterns from the project root.
138
- */
139
- _loadIgnorePatterns(rootPath) {
140
- const ignorePath = path.join(rootPath, '.ship-safeignore');
141
- try {
142
- if (!fs.existsSync(ignorePath)) return [];
143
- return fs.readFileSync(ignorePath, 'utf-8')
144
- .split('\n')
145
- .map(l => l.trim())
146
- .filter(l => l && !l.startsWith('#'));
147
- } catch {
148
- return [];
149
- }
150
- }
151
-
152
- /**
153
- * Get the files this agent should scan.
154
- * If incremental scanning is active (changedFiles in context), returns only changed files.
155
- * Otherwise returns all files. Agents that need the full file list can use context.files directly.
156
- */
157
- getFilesToScan(context) {
158
- return context.changedFiles || context.files;
159
- }
160
-
161
- /**
162
- * Read a file safely, returning null on failure.
163
- */
164
- readFile(filePath) {
165
- try {
166
- return fs.readFileSync(filePath, 'utf-8');
167
- } catch {
168
- return null;
169
- }
170
- }
171
-
172
- /**
173
- * Read a file and return its lines with line numbers.
174
- */
175
- readLines(filePath) {
176
- const content = this.readFile(filePath);
177
- if (!content) return [];
178
- return content.split('\n');
179
- }
180
-
181
- /**
182
- * Get surrounding code context for a finding.
183
- */
184
- getContext(filePath, lineNum, radius = 3) {
185
- const lines = this.readLines(filePath);
186
- if (lines.length === 0) return '';
187
- const start = Math.max(0, lineNum - 1 - radius);
188
- const end = Math.min(lines.length, lineNum + radius);
189
- return lines.slice(start, end).join('\n');
190
- }
191
-
192
- /**
193
- * Check if a line has the ship-safe-ignore suppression comment.
194
- */
195
- isSuppressed(line) {
196
- return /ship-safe-ignore/i.test(line);
197
- }
198
-
199
- /**
200
- * Scan file lines against an array of regex patterns.
201
- * Returns findings for every match.
202
- */
203
- scanFileWithPatterns(filePath, patterns) {
204
- const content = this.readFile(filePath);
205
- if (!content) return [];
206
-
207
- const lines = content.split('\n');
208
- const findings = [];
209
-
210
- for (let i = 0; i < lines.length; i++) {
211
- const line = lines[i];
212
- if (this.isSuppressed(line)) continue;
213
-
214
- for (const p of patterns) {
215
- p.regex.lastIndex = 0;
216
- let match;
217
- while ((match = p.regex.exec(line)) !== null) {
218
- findings.push(createFinding({
219
- file: filePath,
220
- line: i + 1,
221
- column: match.index + 1,
222
- severity: p.severity || 'medium',
223
- category: this.category,
224
- rule: p.rule,
225
- title: p.title,
226
- description: p.description,
227
- matched: match[0],
228
- confidence: p.confidence || 'high',
229
- cwe: p.cwe || null,
230
- owasp: p.owasp || null,
231
- fix: p.fix || null,
232
- }));
233
- }
234
- }
235
- }
236
-
237
- return findings;
238
- }
239
-
240
- /**
241
- * Check if content imports or requires a specific module.
242
- */
243
- hasImport(content, moduleName) {
244
- const importRe = new RegExp(
245
- `(?:import\\s+.*from\\s+['"]${moduleName}['"])|` +
246
- `(?:require\\s*\\(\\s*['"]${moduleName}['"]\\s*\\))`,
247
- 'g'
248
- );
249
- return importRe.test(content);
250
- }
251
- }
252
-
253
- export default BaseAgent;
1
+ /**
2
+ * BaseAgent — Foundation for all security scanning agents
3
+ * ========================================================
4
+ *
5
+ * Every agent in ship-safe extends BaseAgent. It provides:
6
+ * - Standard finding format
7
+ * - File discovery with skip-list support
8
+ * - Severity classification
9
+ * - Consistent output interface
10
+ *
11
+ * USAGE:
12
+ * class MyAgent extends BaseAgent {
13
+ * constructor() { super('MyAgent', 'Description', 'category'); }
14
+ * async analyze(context) { return [findings]; }
15
+ * }
16
+ */
17
+
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import fg from 'fast-glob';
21
+ import { SKIP_DIRS, SKIP_EXTENSIONS, MAX_FILE_SIZE, loadGitignorePatterns } from '../utils/patterns.js';
22
+
23
+ // =============================================================================
24
+ // FINDING FACTORY
25
+ // =============================================================================
26
+
27
+ /**
28
+ * Create a standardized finding object.
29
+ */
30
+ export function createFinding({
31
+ file,
32
+ line = 0,
33
+ column = 0,
34
+ severity = 'medium',
35
+ category = 'vulnerability',
36
+ rule,
37
+ title,
38
+ description,
39
+ matched = '',
40
+ confidence = 'high',
41
+ cwe = null,
42
+ owasp = null,
43
+ fix = null,
44
+ }) {
45
+ return {
46
+ file,
47
+ line,
48
+ column,
49
+ severity,
50
+ category,
51
+ rule,
52
+ title,
53
+ description,
54
+ matched,
55
+ confidence,
56
+ cwe,
57
+ owasp,
58
+ fix,
59
+ };
60
+ }
61
+
62
+ // =============================================================================
63
+ // BASE AGENT CLASS
64
+ // =============================================================================
65
+
66
+ export class BaseAgent {
67
+ /**
68
+ * @param {string} name — Agent name (e.g. 'InjectionTester')
69
+ * @param {string} description — What this agent does
70
+ * @param {string} category — Finding category for scoring
71
+ */
72
+ constructor(name, description, category) {
73
+ this.name = name;
74
+ this.description = description;
75
+ this.category = category;
76
+ }
77
+
78
+ /**
79
+ * Run the agent's analysis on a codebase.
80
+ * Subclasses MUST override this method.
81
+ *
82
+ * @param {object} context — { rootPath, files, recon, options }
83
+ * @returns {Promise<object[]>} — Array of finding objects
84
+ */
85
+ async analyze(context) {
86
+ throw new Error(`${this.name}.analyze() not implemented`);
87
+ }
88
+
89
+ // ── Helpers available to all agents ─────────────────────────────────────────
90
+
91
+ /**
92
+ * Discover all scannable files in a directory.
93
+ * Respects SKIP_DIRS, SKIP_EXTENSIONS, and MAX_FILE_SIZE.
94
+ */
95
+ async discoverFiles(rootPath, extraGlobs = ['**/*']) {
96
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
97
+
98
+ // Respect .gitignore patterns
99
+ const gitignoreGlobs = loadGitignorePatterns(rootPath);
100
+ globIgnore.push(...gitignoreGlobs);
101
+
102
+ // Load .ship-safeignore patterns
103
+ const ignorePatterns = this._loadIgnorePatterns(rootPath);
104
+ for (const p of ignorePatterns) {
105
+ if (p.endsWith('/')) {
106
+ globIgnore.push(`**/${p}**`);
107
+ } else {
108
+ globIgnore.push(`**/${p}`);
109
+ globIgnore.push(p);
110
+ }
111
+ }
112
+
113
+ const allFiles = await fg(extraGlobs, {
114
+ cwd: rootPath,
115
+ absolute: true,
116
+ onlyFiles: true,
117
+ ignore: globIgnore,
118
+ dot: true,
119
+ });
120
+
121
+ return allFiles.filter(file => {
122
+ const ext = path.extname(file).toLowerCase();
123
+ if (SKIP_EXTENSIONS.has(ext)) return false;
124
+ const basename = path.basename(file);
125
+ if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) return false;
126
+ try {
127
+ const stats = fs.statSync(file);
128
+ if (stats.size > MAX_FILE_SIZE) return false;
129
+ } catch {
130
+ return false;
131
+ }
132
+ return true;
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Load .ship-safeignore patterns from the project root.
138
+ */
139
+ _loadIgnorePatterns(rootPath) {
140
+ const ignorePath = path.join(rootPath, '.ship-safeignore');
141
+ try {
142
+ if (!fs.existsSync(ignorePath)) return [];
143
+ return fs.readFileSync(ignorePath, 'utf-8')
144
+ .split('\n')
145
+ .map(l => l.trim())
146
+ .filter(l => l && !l.startsWith('#'));
147
+ } catch {
148
+ return [];
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Get the files this agent should scan.
154
+ * If incremental scanning is active (changedFiles in context), returns only changed files.
155
+ * Otherwise returns all files. Agents that need the full file list can use context.files directly.
156
+ */
157
+ getFilesToScan(context) {
158
+ return context.changedFiles || context.files;
159
+ }
160
+
161
+ /**
162
+ * Read a file safely, returning null on failure.
163
+ */
164
+ readFile(filePath) {
165
+ try {
166
+ return fs.readFileSync(filePath, 'utf-8');
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Read a file and return its lines with line numbers.
174
+ */
175
+ readLines(filePath) {
176
+ const content = this.readFile(filePath);
177
+ if (!content) return [];
178
+ return content.split('\n');
179
+ }
180
+
181
+ /**
182
+ * Get surrounding code context for a finding.
183
+ */
184
+ getContext(filePath, lineNum, radius = 3) {
185
+ const lines = this.readLines(filePath);
186
+ if (lines.length === 0) return '';
187
+ const start = Math.max(0, lineNum - 1 - radius);
188
+ const end = Math.min(lines.length, lineNum + radius);
189
+ return lines.slice(start, end).join('\n');
190
+ }
191
+
192
+ /**
193
+ * Check if a line has the ship-safe-ignore suppression comment.
194
+ */
195
+ isSuppressed(line) {
196
+ return /ship-safe-ignore/i.test(line);
197
+ }
198
+
199
+ /**
200
+ * Scan file lines against an array of regex patterns.
201
+ * Returns findings for every match.
202
+ */
203
+ scanFileWithPatterns(filePath, patterns) {
204
+ const content = this.readFile(filePath);
205
+ if (!content) return [];
206
+
207
+ const lines = content.split('\n');
208
+ const findings = [];
209
+
210
+ for (let i = 0; i < lines.length; i++) {
211
+ const line = lines[i];
212
+ if (this.isSuppressed(line)) continue;
213
+
214
+ for (const p of patterns) {
215
+ p.regex.lastIndex = 0;
216
+ let match;
217
+ while ((match = p.regex.exec(line)) !== null) {
218
+ const finding = createFinding({
219
+ file: filePath,
220
+ line: i + 1,
221
+ column: match.index + 1,
222
+ severity: p.severity || 'medium',
223
+ category: this.category,
224
+ rule: p.rule,
225
+ title: p.title,
226
+ description: p.description,
227
+ matched: match[0],
228
+ confidence: p.confidence || 'high',
229
+ cwe: p.cwe || null,
230
+ owasp: p.owasp || null,
231
+ fix: p.fix || null,
232
+ });
233
+ // Attach surrounding code context (3 lines before/after)
234
+ const start = Math.max(0, i - 3);
235
+ const end = Math.min(lines.length, i + 4);
236
+ finding.codeContext = lines.slice(start, end).map((l, idx) => ({
237
+ line: start + idx + 1,
238
+ text: l,
239
+ highlight: (start + idx) === i,
240
+ }));
241
+ findings.push(finding);
242
+ }
243
+ }
244
+ }
245
+
246
+ return findings;
247
+ }
248
+
249
+ /**
250
+ * Check if content imports or requires a specific module.
251
+ */
252
+ hasImport(content, moduleName) {
253
+ const importRe = new RegExp(
254
+ `(?:import\\s+.*from\\s+['"]${moduleName}['"])|` +
255
+ `(?:require\\s*\\(\\s*['"]${moduleName}['"]\\s*\\))`,
256
+ 'g'
257
+ );
258
+ return importRe.test(content);
259
+ }
260
+ }
261
+
262
+ export default BaseAgent;
@@ -199,6 +199,45 @@ const CONFIG_PATTERNS = [
199
199
  fix: 'Set enabled = true and configure log destination',
200
200
  },
201
201
 
202
+ // ── Terraform (expanded) ───────────────────────────────────────────────────
203
+ {
204
+ rule: 'TERRAFORM_RDS_PUBLIC',
205
+ title: 'Terraform: Publicly Accessible RDS',
206
+ regex: /publicly_accessible\s*=\s*true/g,
207
+ severity: 'critical',
208
+ cwe: 'CWE-284',
209
+ description: 'RDS instance is publicly accessible. Databases should not be exposed to the internet.',
210
+ fix: 'Set publicly_accessible = false and use VPC private subnets',
211
+ },
212
+ {
213
+ rule: 'TERRAFORM_CLOUDFRONT_HTTP',
214
+ title: 'Terraform: CloudFront Allows HTTP',
215
+ regex: /viewer_protocol_policy\s*=\s*["']allow-all["']/g,
216
+ severity: 'high',
217
+ cwe: 'CWE-319',
218
+ description: 'CloudFront distribution allows HTTP traffic. Enforce HTTPS-only.',
219
+ fix: 'Set viewer_protocol_policy = "redirect-to-https"',
220
+ },
221
+ {
222
+ rule: 'TERRAFORM_LAMBDA_ADMIN',
223
+ title: 'Terraform: Lambda with Admin Role',
224
+ regex: /(?:policy_arn|role)\s*=\s*.*AdministratorAccess/g,
225
+ severity: 'critical',
226
+ cwe: 'CWE-250',
227
+ description: 'Lambda function attached to AdministratorAccess policy. Apply least privilege.',
228
+ fix: 'Create a custom IAM policy with only the permissions the function needs',
229
+ },
230
+ {
231
+ rule: 'TERRAFORM_S3_NO_VERSIONING',
232
+ title: 'Terraform: S3 Bucket Without Versioning',
233
+ regex: /versioning\s*\{[\s\S]*?enabled\s*=\s*false/g,
234
+ severity: 'medium',
235
+ cwe: 'CWE-693',
236
+ confidence: 'medium',
237
+ description: 'S3 bucket versioning disabled. Versioning protects against accidental deletion and ransomware.',
238
+ fix: 'Set enabled = true in the versioning block',
239
+ },
240
+
202
241
  // ── Kubernetes ─────────────────────────────────────────────────────────────
203
242
  {
204
243
  rule: 'K8S_PRIVILEGED_CONTAINER',
@@ -247,6 +286,16 @@ const CONFIG_PATTERNS = [
247
286
  fix: 'Create a dedicated ServiceAccount with only needed RBAC bindings',
248
287
  },
249
288
 
289
+ {
290
+ rule: 'K8S_LATEST_IMAGE',
291
+ title: 'Kubernetes: Container Using :latest Tag',
292
+ regex: /image\s*:\s*["']?\S+:latest["']?/g,
293
+ severity: 'medium',
294
+ cwe: 'CWE-1104',
295
+ description: 'Container image uses :latest tag, which is mutable and can change unexpectedly.',
296
+ fix: 'Pin to a specific image digest or version tag',
297
+ },
298
+
250
299
  // ── Docker Compose ─────────────────────────────────────────────────────────
251
300
  {
252
301
  rule: 'COMPOSE_HOST_MOUNT',
@@ -398,6 +447,28 @@ export class ConfigAuditor extends BaseAgent {
398
447
  findings = findings.concat(this.scanFileWithPatterns(file, CONFIG_PATTERNS));
399
448
  }
400
449
 
450
+ // ── Project-level: K8s NetworkPolicy check ─────────────────────────────────
451
+ if (k8sFiles.length > 0) {
452
+ const hasNetworkPolicy = k8sFiles.some(f => {
453
+ const content = this.readFile(f);
454
+ return content && /kind\s*:\s*NetworkPolicy/i.test(content);
455
+ });
456
+ if (!hasNetworkPolicy) {
457
+ findings.push(createFinding({
458
+ file: k8sFiles[0],
459
+ line: 0,
460
+ severity: 'medium',
461
+ category: 'config',
462
+ rule: 'K8S_NO_NETWORK_POLICY',
463
+ title: 'Kubernetes: No NetworkPolicy Defined',
464
+ description: 'Kubernetes manifests found but no NetworkPolicy defined. Without network policies, all pod-to-pod traffic is allowed.',
465
+ matched: 'Missing NetworkPolicy',
466
+ confidence: 'medium',
467
+ fix: 'Add a NetworkPolicy resource to restrict pod-to-pod traffic',
468
+ }));
469
+ }
470
+ }
471
+
401
472
  // ── Scan config files ─────────────────────────────────────────────────────
402
473
  const configFiles = files.filter(f => {
403
474
  const basename = path.basename(f);