@clear-capabilities/agentic-security-scanner 0.195.27 → 0.195.28
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/CHANGELOG.md +14 -0
- package/bin/agentic-security.js +35 -4
- package/dist/{90ebba38d8860ec77376.json → 3eabd90d6d9c8c8dbbd9.json} +4 -3
- package/dist/5856.index.js +634 -0
- package/dist/7033.index.js +74 -14
- package/dist/9220.index.js +24 -1
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -3
- package/src/dataflow/catalog.js +42 -1
- package/src/engine.js +90 -10
- package/src/language/CLAUDE.md +6 -0
- package/src/language/assurance.js +53 -2
- package/src/language/haskell-budget-state.js +85 -0
- package/src/language/haskell-coverage.js +230 -0
- package/src/language/haskell-project-models.js +107 -0
- package/src/language/nix-eval-isolation.js +74 -14
- package/src/llm-validator/bounded-context.js +87 -0
- package/src/llm-validator/claim-groups.js +105 -0
- package/src/llm-validator/disposition.js +264 -0
- package/src/llm-validator/index.js +52 -26
- package/src/pipeline/evidence-record.js +112 -0
- package/src/pipeline/scan-health.js +12 -1
- package/src/posture/artifact-registry.js +2 -0
- package/src/posture/drift.js +19 -1
- package/src/posture/reachability-filter.js +3 -3
- package/src/posture/suppressions.js +21 -0
- package/src/posture/watch-mode.js +22 -1
- package/src/report/disposition-view.js +90 -0
- package/src/report/index.js +47 -0
- package/src/runScan.js +60 -6
- package/src/sast/oidc-trust.js +74 -0
- package/src/sast/pipeline.js +14 -1
- package/src/sast/workflow-flow.js +272 -0
- package/src/sca/basis.js +88 -0
- package/src/scope/flags.js +55 -0
- package/src/scope/inventory.js +241 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Evidence contract (PRD FR 10): additive scan-record and finding-record fields. Nothing here replaces an existing
|
|
2
|
+
// field; every field is derived from data the scan already holds or from read-only git queries on the scan root.
|
|
3
|
+
|
|
4
|
+
import * as crypto from 'node:crypto';
|
|
5
|
+
import * as cp from 'node:child_process';
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
9
|
+
|
|
10
|
+
export const SCAN_RECORD_SCHEMA = 'scan-record/1';
|
|
11
|
+
export const FINDING_RECORD_SCHEMA = 'finding-record/1';
|
|
12
|
+
const DIRTY_CAP = 200;
|
|
13
|
+
|
|
14
|
+
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
15
|
+
|
|
16
|
+
function git(root, args) {
|
|
17
|
+
const r = cp.spawnSync('git', hardenGitArgs(args), { cwd: root, env: hardenGitEnv({}), encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 });
|
|
18
|
+
return r.status === 0 ? r.stdout.toString('utf8') : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Source commit and digests of the files that differ from it, so a result is tied to the exact code scanned. */
|
|
22
|
+
export function sourceState(root) {
|
|
23
|
+
const inside = git(root, ['rev-parse', '--is-inside-work-tree']);
|
|
24
|
+
if (!inside || inside.trim() !== 'true') return { vcs: 'none', commit: null, dirty: null, dirtyFiles: [], dirtyFilesTruncated: false };
|
|
25
|
+
const commit = (git(root, ['rev-parse', 'HEAD']) || '').trim() || null;
|
|
26
|
+
const status = git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']);
|
|
27
|
+
const entries = (status || '').split('\0').filter(Boolean).map((e) => ({ code: e.slice(0, 2), file: e.slice(3) }));
|
|
28
|
+
const dirtyFiles = entries.slice(0, DIRTY_CAP).map((e) => {
|
|
29
|
+
let digest = null;
|
|
30
|
+
try { const abs = path.join(root, e.file); if (fs.lstatSync(abs).isFile()) digest = sha(fs.readFileSync(abs)); } catch { /* deleted */ }
|
|
31
|
+
return { file: e.file, status: e.code.trim() || '?', sha256: digest };
|
|
32
|
+
});
|
|
33
|
+
return { vcs: 'git', commit, dirty: entries.length > 0, dirtyFiles, dirtyFilesTruncated: entries.length > DIRTY_CAP, dirtyCount: entries.length };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Flags that shaped the run, with values that could carry secrets or local paths reduced to their names. */
|
|
37
|
+
export function invocationSummary(argv) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (!a.startsWith('--')) continue;
|
|
42
|
+
let [k, v] = a.split(/=(.*)/s, 2);
|
|
43
|
+
// `--flag value`: keep a short non-path value so the record shows the format and mode that were chosen.
|
|
44
|
+
if (v === undefined && argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && /^[A-Za-z0-9_.,:-]{1,40}$/.test(argv[i + 1]) && !argv[i + 1].includes('/')) v = argv[i + 1];
|
|
45
|
+
out.push(/key|token|secret|password/i.test(k) ? `${k}=<redacted>` : (v !== undefined ? `${k}=${v}` : k));
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildScanRecord({ root, scan, meta, argv = [], engineVersion, mode }) {
|
|
51
|
+
const h = scan.scanHealth || {};
|
|
52
|
+
const model = scan.aiAssistance || null;
|
|
53
|
+
return {
|
|
54
|
+
schema: SCAN_RECORD_SCHEMA,
|
|
55
|
+
tool: { name: 'agentic-security', version: engineVersion || null },
|
|
56
|
+
source: sourceState(root),
|
|
57
|
+
invocation: { flags: invocationSummary(argv), mode: mode || (meta && meta.mode) || 'full' },
|
|
58
|
+
ignorePolicy: scan.scope ? { mode: scan.scope.policy.mode, ruleDigest: scan.scope.policy.ruleDigest, trackedFileSemantics: scan.scope.policy.trackedFileSemantics } : { mode: 'legacy-walk', ruleDigest: null, trackedFileSemantics: false },
|
|
59
|
+
rules: { rulesetVersion: scan._rulesetVersion || null, rulesetSource: scan._rulesetVersionSource || null },
|
|
60
|
+
model: model ? { provider: model.provider, model: model.model, egress: model.egress, stages: model.stages } : { configured: false },
|
|
61
|
+
capability: { status: h.status || null, conditions: Array.isArray(h.conditions) ? h.conditions.length : 0 },
|
|
62
|
+
fileCounts: { scanned: scan.filesScanned ?? null, skipped: scan.scope ? scan.scope.skippedCount : null, included: scan.scope ? scan.scope.includedCount : null },
|
|
63
|
+
// Wall-clock time differs on every run, so deterministic mode leaves it out and the record stays byte-identical.
|
|
64
|
+
elapsedMs: process.env.AGENTIC_SECURITY_DETERMINISTIC === '1' ? null : (meta && meta.durationMs != null ? meta.durationMs : null),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The span a finding occupies: source line through sink line when both are known, else its own line. */
|
|
69
|
+
function spanOf(f) {
|
|
70
|
+
const lines = [f.line, f.source && f.source.line, f.sink && f.sink.line].filter((n) => Number.isInteger(n));
|
|
71
|
+
if (!lines.length) return null;
|
|
72
|
+
return { startLine: Math.min(...lines), endLine: Math.max(...lines) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Finding record. `fileContents` maps scan-relative path to text. Existing fields are untouched. */
|
|
76
|
+
export function annotateFindingRecords(findings, fileContents, { engineVersion = null } = {}) {
|
|
77
|
+
for (const f of findings || []) {
|
|
78
|
+
if (!f || f.record) continue;
|
|
79
|
+
const text = fileContents && (fileContents instanceof Map ? fileContents.get(f.file) : fileContents[f.file]);
|
|
80
|
+
f.record = {
|
|
81
|
+
schema: FINDING_RECORD_SCHEMA,
|
|
82
|
+
stableId: f.stableId || null,
|
|
83
|
+
path: f.file || null,
|
|
84
|
+
sourceHash: typeof text === 'string' ? sha(text) : null,
|
|
85
|
+
span: spanOf(f),
|
|
86
|
+
ruleFamily: f.family || null,
|
|
87
|
+
ruleVersion: engineVersion,
|
|
88
|
+
originalSeverity: f.originalSeverity || f.severity || null,
|
|
89
|
+
confidence: typeof f.confidence === 'number' ? f.confidence : null,
|
|
90
|
+
disposition: f.disposition || null,
|
|
91
|
+
evidence: {
|
|
92
|
+
sourceToSink: f.source || f.sink ? { source: f.source || null, sink: f.sink || null } : null,
|
|
93
|
+
guards: f.falsification ? [f.falsification] : [],
|
|
94
|
+
missingEvidence: (f.dispositionDetail && f.dispositionDetail.missingEvidence) || [],
|
|
95
|
+
},
|
|
96
|
+
reviewerVerdicts: Array.isArray(f.reviewerVerdicts) ? f.reviewerVerdicts : [],
|
|
97
|
+
modelOpinion: f.validator_verdict ? { verdict: f.validator_verdict, confidence: f.llm_confidence ?? null } : null,
|
|
98
|
+
remediation: f.fix || f.remediation || null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return findings;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Raw and displayed findings must reconcile: every raw item is either displayed or accounted for by a named reason.
|
|
106
|
+
* Returns { ok, raw, displayed, accounted, gap }.
|
|
107
|
+
*/
|
|
108
|
+
export function reconcileFindings({ raw, displayed, hidden = {} }) {
|
|
109
|
+
const accounted = Object.values(hidden).reduce((a, b) => a + b, 0);
|
|
110
|
+
const gap = raw - displayed - accounted;
|
|
111
|
+
return { ok: gap === 0, raw, displayed, accounted, gap };
|
|
112
|
+
}
|
|
@@ -42,6 +42,17 @@ export const SCAN_HEALTH_SCHEMA_VERSION = 1;
|
|
|
42
42
|
* never a fabricated all-zero summary.
|
|
43
43
|
* @returns {object} scanHealth per PRD §10.3, additive fields only.
|
|
44
44
|
*/
|
|
45
|
+
// The per-file table travels with the record up to this many files; beyond it only the count is kept
|
|
46
|
+
// (the aggregate stages, rates and incomplete lists above are always present).
|
|
47
|
+
const HASKELL_BYFILE_INLINE_MAX = 2000;
|
|
48
|
+
function summarizeHaskellCoverage(h) {
|
|
49
|
+
if (!h) return null;
|
|
50
|
+
const n = h.byFile ? Object.keys(h.byFile).length : 0;
|
|
51
|
+
if (n <= HASKELL_BYFILE_INLINE_MAX) return { ...h, byFileCount: n };
|
|
52
|
+
const { byFile, ...rest } = h;
|
|
53
|
+
return { ...rest, byFileCount: n, byFileOmitted: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engineErrors = null, deepStatus = null, analyzerCoverage = null, lineageStatus = null, languageCoverage = null, supplyChain = null } = {}) {
|
|
46
57
|
const conditions = [];
|
|
47
58
|
const safeAnnotatorErrors = Array.isArray(annotatorErrors) ? annotatorErrors : [];
|
|
@@ -130,7 +141,7 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
|
|
|
130
141
|
schemaVersion: SCAN_HEALTH_SCHEMA_VERSION,
|
|
131
142
|
status,
|
|
132
143
|
...(trustFindings.length ? { buildTrust: { findings: trustFindings.length, uncertain: trustUncertain } } : {}),
|
|
133
|
-
...(languageCoverage ? { languageCoverage: { totals: languageCoverage.totals ?? null, byKind: languageCoverage.byKind ?? null, ...(languageCoverage.limitations ? { limitations: languageCoverage.limitations } : {}), ...(languageCoverage.capabilities ? { capabilities: languageCoverage.capabilities } : {}), ...(languageCoverage.optionalModes ? { optionalModes: languageCoverage.optionalModes } : {}) } } : {}),
|
|
144
|
+
...(languageCoverage ? { languageCoverage: { totals: languageCoverage.totals ?? null, byKind: languageCoverage.byKind ?? null, ...(languageCoverage.limitations ? { limitations: languageCoverage.limitations } : {}), ...(languageCoverage.capabilities ? { capabilities: languageCoverage.capabilities } : {}), ...(languageCoverage.optionalModes ? { optionalModes: languageCoverage.optionalModes } : {}), ...(languageCoverage.haskell ? { haskell: summarizeHaskellCoverage(languageCoverage.haskell) } : {}) } } : {}),
|
|
134
145
|
files: {
|
|
135
146
|
expected: scanMeta?.checkpoint?.total ?? null,
|
|
136
147
|
scanned: scanMeta?.filesScanned ?? null,
|
|
@@ -253,6 +253,8 @@ export const ARTIFACT_REGISTRY = [
|
|
|
253
253
|
{ name: 'integrations.yml', kind: 'file', classification: 'operator-config' },
|
|
254
254
|
{ name: 'profile.yml', kind: 'file', classification: 'operator-config' },
|
|
255
255
|
{ name: 'sca-policy.yml', kind: 'file', classification: 'operator-config' },
|
|
256
|
+
{ name: 'haskell-budget.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/language/haskell-budget-state.js (saveBudgetState)', note: 'Haskell parse budgets in effect and every file left unanalysed when one was hit, with a content digest, so the next scan can say what finished since. Rewritten by every scan that has Haskell work left.' },
|
|
257
|
+
{ name: 'haskell-models.json', kind: 'file', classification: 'operator-config', note: 'reviewed project models for Haskell wrappers; only ever loaded by language/haskell-project-models.js' },
|
|
256
258
|
{ name: 'suppressions.yml', kind: 'file', classification: 'operator-config', note: 'audit-tier suppression config; only ever loaded, never saved, by posture/suppressions.js' },
|
|
257
259
|
{ name: 'network-policy.json', kind: 'file', classification: 'operator-config', note: 'documented as an Inputs-section artifact in posture/network-policy-import.js, not a scanner-written digest' },
|
|
258
260
|
{ name: 'privacy-taxonomy.json', kind: 'file', classification: 'operator-config', note: 'FR-402 privacy data-classification taxonomy overrides/additions — read by dataflow/privacy-taxonomy.js, never written by the scanner' },
|
package/src/posture/drift.js
CHANGED
|
@@ -78,7 +78,21 @@ export function driftBetween(scanA, scanB) {
|
|
|
78
78
|
// Findings — use kind+file+line+vuln as the stable key.
|
|
79
79
|
const allFindingsA = [...(scanA.findings || []), ...(scanA.logicVulns || [])];
|
|
80
80
|
const allFindingsB = [...(scanB.findings || []), ...(scanB.logicVulns || [])];
|
|
81
|
-
|
|
81
|
+
// A claim grouped under an equivalent primary (llm-validator/claim-groups.js) is still present: it keeps its identity for drift,
|
|
82
|
+
// so grouping never shows a finding as fixed, and ungrouping never shows one as new.
|
|
83
|
+
const findings = _diffSets([...allFindingsA, ...(scanA.groupedClaims || [])], [...allFindingsB, ...(scanB.groupedClaims || [])], _findingKey);
|
|
84
|
+
// PRD FR 01: a finding that disappeared because the newer scan did not cover its file is out of scope, never fixed.
|
|
85
|
+
const outOfScope = [];
|
|
86
|
+
if (scanB.scope && Array.isArray(scanB.scope.included)) {
|
|
87
|
+
const inB = new Set(scanB.scope.included);
|
|
88
|
+
const keep = [];
|
|
89
|
+
for (const f of findings.removed) (f && f.file && !inB.has(f.file) ? outOfScope : keep).push(f);
|
|
90
|
+
findings.removed = keep;
|
|
91
|
+
findings.outOfScope = outOfScope;
|
|
92
|
+
} else {
|
|
93
|
+
findings.outOfScope = [];
|
|
94
|
+
findings.scopeUnknown = !!(scanB.scope && scanB.scope.includedOmitted);
|
|
95
|
+
}
|
|
82
96
|
const severity = _severityDelta(allFindingsA, allFindingsB);
|
|
83
97
|
const authBoundaries = _authBoundaryDelta(scanA.routes || [], scanB.routes || []);
|
|
84
98
|
const dataClasses = _dataClassDelta(scanA.routes || [], scanB.routes || []);
|
|
@@ -163,6 +177,10 @@ export function driftToMarkdown(drift) {
|
|
|
163
177
|
lines.push(`**Findings fixed: ${drift.findings.removed.length}**`);
|
|
164
178
|
lines.push('');
|
|
165
179
|
}
|
|
180
|
+
if (drift.findings.outOfScope && drift.findings.outOfScope.length) {
|
|
181
|
+
lines.push(`**Findings not covered by this scan (out of scope, not fixed): ${drift.findings.outOfScope.length}**`);
|
|
182
|
+
lines.push('');
|
|
183
|
+
}
|
|
166
184
|
if (drift.dataClasses.newlyExposed.length) {
|
|
167
185
|
lines.push(`**Newly exposed data classes: ${drift.dataClasses.newlyExposed.join(', ')}**`);
|
|
168
186
|
lines.push('');
|
|
@@ -25,12 +25,12 @@ const SEVERITY_DEMOTE = {
|
|
|
25
25
|
// keeps full severity. route-reachable-via-function and function-reachable
|
|
26
26
|
// both keep full severity because the vulnerable function is provably
|
|
27
27
|
// called; import-reachable also keeps full severity (imported = uncertain
|
|
28
|
-
// but plausible).
|
|
28
|
+
// but plausible). manifest-only and transitive-only record only that no use was
|
|
29
|
+
// observed, which is unknown reachability, and unknown reachability cannot lower
|
|
30
|
+
// severity (PRD FR 08). Only positive evidence (unreachable, build-only) demotes.
|
|
29
31
|
const DEMOTE_SCA_TIERS = new Set([
|
|
30
32
|
'unreachable', // function never called from project
|
|
31
33
|
'build-only', // dev/build-time dependency only
|
|
32
|
-
'manifest-only', // declared, but no use observed
|
|
33
|
-
'transitive-only', // transitive dep, scope unclear
|
|
34
34
|
]);
|
|
35
35
|
|
|
36
36
|
export function demoteUnreachable(findings, opts = {}) {
|
|
@@ -39,6 +39,7 @@ import * as path from 'node:path';
|
|
|
39
39
|
import * as yaml from '../util/yaml.js';
|
|
40
40
|
import { statePath, safeWriteState } from './state-dir.js';
|
|
41
41
|
import { loadApproverRegistry, verifyApprover, requiredRolesFor } from '../fix/approver-registry.js';
|
|
42
|
+
import { contentFingerprint } from '../llm-validator/disposition.js';
|
|
42
43
|
|
|
43
44
|
const MS_PER_DAY = 86400000;
|
|
44
45
|
const SOFT_TTL_DAYS = 30;
|
|
@@ -63,6 +64,15 @@ export function saveSoftAccepted(scanRoot, items) {
|
|
|
63
64
|
safeWriteState(fp, JSON.stringify({ accepted: items }, null, 2));
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
// Content fingerprint of a scanned file, or null when it cannot be read.
|
|
68
|
+
function _diskFingerprint(scanRoot, file) {
|
|
69
|
+
try {
|
|
70
|
+
const abs = path.resolve(scanRoot, file);
|
|
71
|
+
if (abs !== path.resolve(scanRoot) && !abs.startsWith(path.resolve(scanRoot) + path.sep)) return null;
|
|
72
|
+
return contentFingerprint(fs.readFileSync(abs, 'utf8'));
|
|
73
|
+
} catch { return null; }
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
export function addSoftAcceptance(scanRoot, finding, reason) {
|
|
67
77
|
const items = loadSoftAccepted(scanRoot);
|
|
68
78
|
const expires = new Date(_now() + SOFT_TTL_DAYS * MS_PER_DAY).toISOString().slice(0, 10);
|
|
@@ -74,6 +84,8 @@ export function addSoftAcceptance(scanRoot, finding, reason) {
|
|
|
74
84
|
reason: reason || 'vibecoded for now',
|
|
75
85
|
accepted_at: new Date().toISOString().slice(0, 10),
|
|
76
86
|
expires_at: expires,
|
|
87
|
+
// PRD FR 05: bind the acceptance to the code it was made on, so a changed file reopens it.
|
|
88
|
+
fingerprint: _diskFingerprint(scanRoot, finding.file),
|
|
77
89
|
});
|
|
78
90
|
saveSoftAccepted(scanRoot, items);
|
|
79
91
|
return expires;
|
|
@@ -156,6 +168,15 @@ export function applySuppressions(findings, scanRoot, profile) {
|
|
|
156
168
|
kept.push({ ...f, _suppressionExpired: true });
|
|
157
169
|
continue;
|
|
158
170
|
}
|
|
171
|
+
// PRD FR 05: an entry bound to a content fingerprint reopens when the
|
|
172
|
+
// file changes or disappears. Entries without one keep the legacy behaviour.
|
|
173
|
+
if (matched.fingerprint) {
|
|
174
|
+
const now_fp = _diskFingerprint(scanRoot, f.file);
|
|
175
|
+
if (now_fp !== matched.fingerprint) {
|
|
176
|
+
kept.push({ ...f, _suppressionReopened: now_fp ? 'source changed since the suppression was recorded' : 'suppressed source is no longer available' });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
159
180
|
// Pro: validate the entry still passes
|
|
160
181
|
if (isPro) {
|
|
161
182
|
// Same registry/category shape FR-307 already uses for high-impact
|
|
@@ -25,6 +25,7 @@ import * as path from 'node:path';
|
|
|
25
25
|
|
|
26
26
|
import { stateDir, statePath, stateWritesEnabled } from './state-dir.js';
|
|
27
27
|
import { isLanguageSource, isLanguageManifest, isLanguageExcludedPath, isExplicitExport, impactedFiles } from '../language/discovery.js';
|
|
28
|
+
import { buildScanInventory } from '../scope/inventory.js';
|
|
28
29
|
const STATUS_MD = 'watch-status.md';
|
|
29
30
|
const STATUS_JSON = 'watch-status.json';
|
|
30
31
|
const DEBOUNCE_MS = 350;
|
|
@@ -33,6 +34,25 @@ const MAX_BURST = 50; // ignore beyond this # of rapid events
|
|
|
33
34
|
const SCAN_EXT_RE = /\.(?:[jt]sx?|mjs|cjs|py|java|kt|go|rb|php|cs|c|cc|cpp|h|hpp|rs|sol|vy|swift|dart|toml|yml|yaml|json|tf|tfvars|bicep)$/i;
|
|
34
35
|
const IGNORE_DIR_RE = /(?:^|\/)(?:\.git|node_modules|\.bench-cache|dist|build|\.next|coverage|\.agentic-security)(?:$|\/)/;
|
|
35
36
|
|
|
37
|
+
// Under Git scope (PRD FR 01) the watcher asks the same inventory the scan uses, so a change to a Git-ignored file does not
|
|
38
|
+
// trigger a rescan, and a tracked file under a directory the default list would skip (`dist/`, `build/`) still does. A change
|
|
39
|
+
// to an ignore source rebuilds the inventory. A path the inventory has not seen (a new file) rebuilds it, at most once a second.
|
|
40
|
+
const IGNORE_SOURCE = /(?:^|\/)\.gitignore$|^\.git\/info\/exclude$/;
|
|
41
|
+
export function createScopeFilter(scanRoot, scope = {}, { build = buildScanInventory, now = Date.now } = {}) {
|
|
42
|
+
let included = null;
|
|
43
|
+
let builtAt = -Infinity;
|
|
44
|
+
const rebuild = () => { included = new Set(build(scanRoot, scope).included); builtAt = now(); };
|
|
45
|
+
rebuild();
|
|
46
|
+
return (rel) => {
|
|
47
|
+
if (!rel || /^\.agentic-security(?:\/|$)/.test(rel)) return false;
|
|
48
|
+
if (IGNORE_SOURCE.test(rel)) { rebuild(); return true; }
|
|
49
|
+
if (/^\.git(?:\/|$)/.test(rel)) return false;
|
|
50
|
+
if (included.has(rel)) return true;
|
|
51
|
+
if (now() - builtAt >= 1000) rebuild();
|
|
52
|
+
return included.has(rel);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
36
56
|
function _isScanable(rel) {
|
|
37
57
|
if (!rel) return false;
|
|
38
58
|
// An explicit export is evidence even though it lives in a build directory.
|
|
@@ -162,6 +182,7 @@ export function readStatus(scanRoot) {
|
|
|
162
182
|
export async function watchProject(scanRoot, onChange, opts = {}) {
|
|
163
183
|
if (process.env.AGENTIC_SECURITY_NO_WATCH === '1') return { stop: async () => {}, _disabled: true };
|
|
164
184
|
const recursive = opts.recursive !== false;
|
|
185
|
+
const eligible = typeof opts.isEligible === 'function' ? opts.isEligible : _isScanable;
|
|
165
186
|
const ac = new AbortController();
|
|
166
187
|
let timer = null;
|
|
167
188
|
const pending = new Set();
|
|
@@ -183,7 +204,7 @@ export async function watchProject(scanRoot, onChange, opts = {}) {
|
|
|
183
204
|
for await (const evt of fs.watch(scanRoot, { recursive, signal: ac.signal })) {
|
|
184
205
|
if (stopped) break;
|
|
185
206
|
const rel = String(evt.filename || '').replace(/\\/g, '/');
|
|
186
|
-
if (!
|
|
207
|
+
if (!eligible(rel)) continue;
|
|
187
208
|
pending.add(path.join(scanRoot, rel));
|
|
188
209
|
if (timer) clearTimeout(timer);
|
|
189
210
|
timer = setTimeout(flush, DEBOUNCE_MS);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Report view over dispositions (PRD FR 09). Display only: it reads a scan and
|
|
2
|
+
// returns groups and counts, and never changes stored findings, so hiding a
|
|
3
|
+
// category here cannot mark anything assessed or imply remediation. Raw and
|
|
4
|
+
// displayed counts reconcile: raw = shown + every hidden bucket.
|
|
5
|
+
|
|
6
|
+
import { DISPOSITIONS as D } from '../llm-validator/disposition.js';
|
|
7
|
+
|
|
8
|
+
export const VIEW_SCHEMA = 'report-view/1';
|
|
9
|
+
export const EXCLUDABLE_KINDS = Object.freeze(['sca', 'generated', 'secrets', 'logic']);
|
|
10
|
+
|
|
11
|
+
const LANG_BY_EXT = { js: 'javascript', mjs: 'javascript', cjs: 'javascript', ts: 'typescript', tsx: 'typescript', jsx: 'javascript', py: 'python', java: 'java', go: 'go', rb: 'ruby', rs: 'rust', cs: 'csharp', kt: 'kotlin', php: 'php', hs: 'haskell', lhs: 'haskell', nix: 'nix', yml: 'yaml', yaml: 'yaml', tf: 'terraform' };
|
|
12
|
+
export function languageOf(file) {
|
|
13
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(String(file || ''));
|
|
14
|
+
return (m && LANG_BY_EXT[m[1].toLowerCase()]) || 'other';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const GENERATED = /(?:^|\/)(?:dist|build|buck-out|dist-newstyle|\.stack-work|generated|gen)\//i;
|
|
18
|
+
|
|
19
|
+
function groupOf(f) {
|
|
20
|
+
const d = f.disposition;
|
|
21
|
+
if (d === D.CONFIRMED) return 'confirmed';
|
|
22
|
+
if (d === D.HARDENING) return 'hardening';
|
|
23
|
+
if (d === D.REFUTED) return 'history';
|
|
24
|
+
if (d === D.NEEDS_REVIEW) return 'needsReview';
|
|
25
|
+
return 'unvalidated';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Parse `--exclude-kind sca,generated` style values. Returns { kinds, errors }. */
|
|
29
|
+
export function parseExcludeKinds(v) {
|
|
30
|
+
const kinds = []; const errors = [];
|
|
31
|
+
for (const k of String(v ?? '').split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
32
|
+
if (EXCLUDABLE_KINDS.includes(k)) kinds.push(k); else errors.push(`--exclude-kind: unknown kind "${k}" (known: ${EXCLUDABLE_KINDS.join(', ')})`);
|
|
33
|
+
}
|
|
34
|
+
return { kinds, errors };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} scan
|
|
39
|
+
* @param {{excludeKinds?: string[], filters?: {category?: string, language?: string, package?: string, path?: string, disposition?: string, minConfidence?: number}}} [o]
|
|
40
|
+
*/
|
|
41
|
+
export function buildDispositionView(scan, { excludeKinds = [], filters = {} } = {}) {
|
|
42
|
+
const buckets = [
|
|
43
|
+
['code', scan.findings || [], null],
|
|
44
|
+
['secrets', scan.secrets || [], 'secrets'],
|
|
45
|
+
['logic', scan.logicVulns || [], 'logic'],
|
|
46
|
+
['sca', scan.supplyChain || [], 'sca'],
|
|
47
|
+
];
|
|
48
|
+
const hidden = { byKind: {}, byFilter: 0 };
|
|
49
|
+
const groups = { confirmed: [], needsReview: [], unvalidated: [], hardening: [], history: [] };
|
|
50
|
+
let raw = 0;
|
|
51
|
+
for (const [bucket, items, kind] of buckets) {
|
|
52
|
+
for (const f of items) {
|
|
53
|
+
raw++;
|
|
54
|
+
const isSca = bucket === 'sca';
|
|
55
|
+
const k = kind || (GENERATED.test(String(f.file || '')) ? 'generated' : null);
|
|
56
|
+
if (k && excludeKinds.includes(k)) { hidden.byKind[k] = (hidden.byKind[k] || 0) + 1; continue; }
|
|
57
|
+
if (filters.category && String(f.family || f.category || '') !== filters.category) { hidden.byFilter++; continue; }
|
|
58
|
+
if (filters.language && languageOf(f.file) !== filters.language) { hidden.byFilter++; continue; }
|
|
59
|
+
if (filters.package && String(f.package || f.component || f.name || '') !== filters.package) { hidden.byFilter++; continue; }
|
|
60
|
+
if (filters.path && !String(f.file || '').startsWith(filters.path)) { hidden.byFilter++; continue; }
|
|
61
|
+
if (filters.disposition && (isSca ? 'unvalidated' : groupOf(f)) !== filters.disposition && f.disposition !== filters.disposition) { hidden.byFilter++; continue; }
|
|
62
|
+
if (typeof filters.minConfidence === 'number' && (typeof f.confidence === 'number' ? f.confidence : 1) < filters.minConfidence) { hidden.byFilter++; continue; }
|
|
63
|
+
groups[isSca ? 'unvalidated' : groupOf(f)].push(f);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Refuted findings were moved to the suppression ledger by the engine; they appear as history.
|
|
67
|
+
for (const s of scan.suppressions || []) if (s && s.disposition === D.REFUTED) groups.history.push(s);
|
|
68
|
+
const counts = Object.fromEntries(Object.entries(groups).map(([k, v]) => [k, v.length]));
|
|
69
|
+
// History comes from the ledger, not from the live buckets, so it is outside the raw/shown reconciliation.
|
|
70
|
+
const shown = counts.confirmed + counts.needsReview + counts.unvalidated + counts.hardening;
|
|
71
|
+
const hiddenTotal = Object.values(hidden.byKind).reduce((a, b) => a + b, 0) + hidden.byFilter;
|
|
72
|
+
return {
|
|
73
|
+
schema: VIEW_SCHEMA,
|
|
74
|
+
excludeKinds: [...excludeKinds],
|
|
75
|
+
filters: { ...filters },
|
|
76
|
+
counts,
|
|
77
|
+
actionable: counts.confirmed + counts.needsReview,
|
|
78
|
+
raw, shown, hidden,
|
|
79
|
+
reconciles: raw === shown + hiddenTotal,
|
|
80
|
+
note: hiddenTotal ? 'Hidden items are still stored in the scan and are not assessed or remediated by hiding them.' : null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function viewLine(v) {
|
|
85
|
+
if (!v) return null;
|
|
86
|
+
const hid = Object.entries(v.hidden.byKind).map(([k, n]) => `${n} ${k}`);
|
|
87
|
+
if (v.hidden.byFilter) hid.push(`${v.hidden.byFilter} filtered`);
|
|
88
|
+
return `Findings: ${v.counts.confirmed} confirmed, ${v.counts.needsReview} needs review, ${v.counts.unvalidated} unvalidated, ${v.counts.hardening} hardening advice, ${v.counts.history} refuted history`
|
|
89
|
+
+ (hid.length ? `; hidden from this view (still stored, not assessed): ${hid.join(', ')}` : '');
|
|
90
|
+
}
|
package/src/report/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Report writers — JSON / Markdown / SARIF.
|
|
2
|
+
import { viewLine } from './disposition-view.js';
|
|
2
3
|
import * as crypto from 'node:crypto';
|
|
3
4
|
import { _isCustomSuppressed } from '../engine.js';
|
|
4
5
|
import { alertFace, approveFace } from './mascot.js';
|
|
@@ -290,6 +291,14 @@ export function normalizeFindings(scan){
|
|
|
290
291
|
blastRadius: f.blastRadius || null,
|
|
291
292
|
// Sentinel-parity (FR-PREC, FR-L3) preserved fields.
|
|
292
293
|
stableId: f.stableId || null,
|
|
294
|
+
// FR 05 / FR 10: the final disposition, why, and the evidence record. Present only when the engine computed them.
|
|
295
|
+
...(f.disposition ? { disposition: f.disposition, dispositionDetail: f.dispositionDetail || null } : {}),
|
|
296
|
+
...(Array.isArray(f.claims) && f.claims.length > 1 ? { claims: f.claims } : {}),
|
|
297
|
+
...(f.record ? { record: f.record } : {}),
|
|
298
|
+
// FR 06: every workflow finding names the workflow, job, step, input origin, sensitive operation and privilege boundary.
|
|
299
|
+
...(f.workflow ? { workflow: f.workflow, job: f.job ?? null, step: f.step ?? null, inputOrigin: f.inputOrigin ?? null, inputOriginKind: f.inputOriginKind ?? null, sensitiveOperation: f.sensitiveOperation ?? null, privilegeBoundary: f.privilegeBoundary ?? null, guards: f.guards ?? [], missingEvidence: f.missingEvidence ?? (f.dispositionDetail && f.dispositionDetail.missingEvidence) ?? [] } : {}),
|
|
300
|
+
...(f.oidcTrust ? { oidcTrust: f.oidcTrust } : {}),
|
|
301
|
+
...(f.validator_reject_retained ? { validator_reject_retained: true } : {}),
|
|
293
302
|
confidenceTier: f.confidenceTier || null,
|
|
294
303
|
exploitability: typeof f.exploitability === 'number' ? f.exploitability : null,
|
|
295
304
|
exploitabilityTier: f.exploitabilityTier || null,
|
|
@@ -569,6 +578,8 @@ export function normalizeFindings(scan){
|
|
|
569
578
|
version: sc.version,
|
|
570
579
|
cveAliases: sc.cveAliases || [],
|
|
571
580
|
osvId: sc.osvId || null,
|
|
581
|
+
...(sc.basis ? { basis: sc.basis } : {}),
|
|
582
|
+
...(sc.advisoryIds ? { advisoryIds: sc.advisoryIds, occurrences: sc.occurrences } : {}),
|
|
572
583
|
advisory: sc.advisory || sc.description || sc.summary || '',
|
|
573
584
|
// Hackage (and any matcher that decides from ranges): WHY the dependency matched. Without these a `possibly-affected` finding
|
|
574
585
|
// (only a declared range is known) reads exactly like an `affected` one (the resolved version is inside the range).
|
|
@@ -764,6 +775,13 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
764
775
|
// "analysis complete". Null when the engine did not compute one (e.g. a
|
|
765
776
|
// hand-built `scan` object in a test), same convention as the S7 fields
|
|
766
777
|
// above; a real scan always computes it.
|
|
778
|
+
...(scan.scanRecord ? { scanRecord: scan.scanRecord } : {}),
|
|
779
|
+
...(scan.reportView ? { reportView: scan.reportView } : {}),
|
|
780
|
+
...(scan.dispositionCounts ? { dispositionCounts: scan.dispositionCounts } : {}),
|
|
781
|
+
// PRD FR 05: every candidate stays discoverable. Equivalent claims grouped under a primary are listed here, and the
|
|
782
|
+
// reconciliation shows raw, grouped, refuted and active counts with nothing counted twice.
|
|
783
|
+
...(scan.candidateReconciliation ? { candidateReconciliation: scan.candidateReconciliation } : {}),
|
|
784
|
+
...(Array.isArray(scan.groupedClaims) && scan.groupedClaims.length ? { groupedClaims: scan.groupedClaims.map((g) => ({ id: g.id ?? null, stableId: g.stableId ?? null, parser: g.parser ?? null, vuln: g.vuln ?? null, cwe: g.cwe ?? null, severity: g.severity ?? null, file: g.file ?? null, line: g.line ?? null, groupedInto: g.groupedInto ?? null, disposition: g.disposition ?? null })) } : {}),
|
|
767
785
|
scanHealth: scan.scanHealth || null,
|
|
768
786
|
// X-207: advisory hypotheses a caller put in the finding list; excluded from `findings`, listed here so it is never silent.
|
|
769
787
|
...(Array.isArray(scan.advisoryExcluded) && scan.advisoryExcluded.length ? { advisoryExcluded: scan.advisoryExcluded } : {}),
|
|
@@ -1079,6 +1097,29 @@ export const TOOL_CAVEATS = Object.freeze([
|
|
|
1079
1097
|
},
|
|
1080
1098
|
]);
|
|
1081
1099
|
|
|
1100
|
+
// FR 02: one reading of the Haskell coverage record, shared by the CLI, HTML and SARIF outputs so they cannot disagree.
|
|
1101
|
+
export function haskellCoverageSummary(scan) {
|
|
1102
|
+
const h = scan && scan.scanHealth && scan.scanHealth.languageCoverage && scan.scanHealth.languageCoverage.haskell;
|
|
1103
|
+
if (!h || !h.present) return null;
|
|
1104
|
+
const pct = (r) => (r == null ? 'n/a' : `${(r * 100).toFixed(1)}%`);
|
|
1105
|
+
return {
|
|
1106
|
+
status: h.capability.status,
|
|
1107
|
+
discovered: h.totals.discovered, eligible: h.totals.eligible, eligibleHs: h.totals.eligibleHs,
|
|
1108
|
+
parseRate: h.stages.parse.rate, semanticRate: h.stages.semantic.rate,
|
|
1109
|
+
parseText: pct(h.stages.parse.rate), semanticText: pct(h.stages.semantic.rate),
|
|
1110
|
+
functions: h.functions, unresolvedCalls: h.unresolvedCalls,
|
|
1111
|
+
ruleFamilies: h.ruleFamilies.map((f) => ({ id: f.id, status: f.status, findings: f.findings })),
|
|
1112
|
+
reasons: h.capability.reasons, limitations: h.capability.limitations,
|
|
1113
|
+
incompleteEntries: h.incompleteEntries.length,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function haskellCoverageLine(scan) {
|
|
1118
|
+
const h = haskellCoverageSummary(scan);
|
|
1119
|
+
if (!h) return null;
|
|
1120
|
+
return `Haskell coverage: ${h.status}; ${h.eligibleHs} eligible .hs file(s), parsed ${h.parseText}, semantic analysis completed ${h.semanticText}; ${h.functions.lowered}/${h.functions.discovered} function(s) analysed, ${h.unresolvedCalls == null ? 'n/a' : h.unresolvedCalls} unresolved call(s)`;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1082
1123
|
export function toSARIF(scan, meta={}){
|
|
1083
1124
|
const findings = normalizeFindings(scan);
|
|
1084
1125
|
const ruleMap = new Map();
|
|
@@ -1128,6 +1169,8 @@ export function toSARIF(scan, meta={}){
|
|
|
1128
1169
|
// /security-trend regression analysis can attribute deltas to rule
|
|
1129
1170
|
// changes vs. code changes.
|
|
1130
1171
|
properties: {
|
|
1172
|
+
...(haskellCoverageSummary(scan) ? { haskellCoverage: haskellCoverageSummary(scan) } : {}),
|
|
1173
|
+
...(scan.reportView ? { reportView: scan.reportView } : {}),
|
|
1131
1174
|
...(scan && scan._rulesetVersion ? { rulesetVersion: scan._rulesetVersion } : {}),
|
|
1132
1175
|
...(scan && scan._rulesetVersionSource ? { rulesetVersionSource: scan._rulesetVersionSource } : {}),
|
|
1133
1176
|
...(scan && scan._rulesetVersionMismatch ? { rulesetVersionMismatch: scan._rulesetVersionMismatch } : {}),
|
|
@@ -1376,6 +1419,8 @@ export function toHTML(scan, meta = {}) {
|
|
|
1376
1419
|
<div class="card"><h2>STRIDE coverage</h2><table><tbody>${strideRows}</tbody></table></div>
|
|
1377
1420
|
<div class="card"><h2>Top files</h2><table><tbody>${hotRows}</tbody></table></div>
|
|
1378
1421
|
</div>
|
|
1422
|
+
${(() => { const vl = viewLine(scan.reportView); return vl ? `<div class="card" style="margin-bottom:24px"><h2>Findings by disposition</h2><div class="meta">${_esc(vl)}</div></div>` : ''; })()}
|
|
1423
|
+
${(() => { const h = haskellCoverageSummary(scan); if (!h) return ''; return `<div class="card" style="margin-bottom:24px"><h2>Haskell coverage — ${_esc(h.status)}</h2><div class="meta">${_esc(haskellCoverageLine(scan))}</div><div class="meta">Rule families: ${h.ruleFamilies.map((f) => `${_esc(f.id)} (${_esc(f.status)})`).join(', ')}</div>${h.reasons.map((r) => `<div class="meta">• ${_esc(r)}</div>`).join('')}${h.limitations.map((r) => `<div class="meta">• ${_esc(r)}</div>`).join('')}</div>`; })()}
|
|
1379
1424
|
<div class="filters">
|
|
1380
1425
|
<input id="q" placeholder="Filter by file, vuln, CWE…" />
|
|
1381
1426
|
<select id="sev"><option value="">All severities</option><option>critical</option><option>high</option><option>medium</option><option>low</option><option>info</option></select>
|
|
@@ -1688,6 +1733,8 @@ export function toShipVerdict(scan, options = {}) {
|
|
|
1688
1733
|
const conditions = Array.isArray(scan.scanHealth.conditions) ? scan.scanHealth.conditions : [];
|
|
1689
1734
|
lines.push(c(` ${scan.scanHealth.status}: ${conditions[0] || 'analysis did not complete cleanly'}${conditions.length > 1 ? ` (+${conditions.length - 1} more)` : ''}`, SEV_COLOR.high));
|
|
1690
1735
|
}
|
|
1736
|
+
{ const hl = haskellCoverageLine(scan); if (hl) lines.push(c(` ${hl}`, DIM)); }
|
|
1737
|
+
{ const vl = viewLine(scan.reportView); if (vl) lines.push(c(` ${vl}`, DIM)); }
|
|
1691
1738
|
lines.push(` • ${sev.critical} critical · ${sev.high} high · ${advisoryCount} advisory`);
|
|
1692
1739
|
// Per-category 0..100 score bars — Secrets / Permissions / Hooks / MCP / Agents.
|
|
1693
1740
|
// Only render when at least one category has been contributed to (skip the
|
package/src/runScan.js
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
import * as fs from 'node:fs/promises';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import * as cp from 'node:child_process';
|
|
6
|
+
import * as fsSync from 'node:fs';
|
|
6
7
|
import { listFiles } from './util/glob.js';
|
|
8
|
+
import { buildScanInventory } from './scope/inventory.js';
|
|
7
9
|
import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
|
|
8
10
|
import { runFullScan, shouldScan, isKubernetesManifest, isCloudFormationTemplate, isInstructionFile } from './engine.js';
|
|
9
11
|
import { appendScanSnapshot } from './posture/security-trend.js';
|
|
10
12
|
import { recover as recoverFixHistory } from './posture/fix-history.js';
|
|
11
13
|
import { stampScan } from './posture/ruleset-version.js';
|
|
14
|
+
import { annotateFindingRecords, buildScanRecord } from './pipeline/evidence-record.js';
|
|
15
|
+
import { SCANNER_VERSION } from './posture/version.js';
|
|
12
16
|
import { isLanguageManifest, isLanguageExcludedPath, isLanguageSource, readExplicitExports, WALK_IGNORE_GLOBS, impactedFiles, buildImportGraph, importClosure } from './language/discovery.js';
|
|
13
17
|
|
|
14
18
|
const DEP_FILE_NAMES = new Set([
|
|
@@ -77,8 +81,23 @@ const DEFAULT_IGNORE = [
|
|
|
77
81
|
...WALK_IGNORE_GLOBS,
|
|
78
82
|
];
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
// Directory names a Git-scoped scan still refuses regardless of Git state:
|
|
85
|
+
// dependency trees and Git's own metadata. The broader DEFAULT_IGNORE list
|
|
86
|
+
// (vendor, test, bin, ...) does NOT apply in Git-scoped mode: tracked code is
|
|
87
|
+
// not discarded because of its folder name (PRD FR 01).
|
|
88
|
+
const GIT_SCOPE_SAFETY_IGNORE = ['**/node_modules/**', '**/.git/**'];
|
|
89
|
+
|
|
90
|
+
export async function readTree(root, { ignore = [], gitScope = process.env.AGENTIC_SECURITY_GIT_SCOPE === '1', scope = {} } = {}) {
|
|
91
|
+
let entries;
|
|
92
|
+
let inventory = null;
|
|
93
|
+
if (gitScope) {
|
|
94
|
+
// One inventory decides eligibility before any content is read.
|
|
95
|
+
inventory = buildScanInventory(root, scope);
|
|
96
|
+
const drop = [...GIT_SCOPE_SAFETY_IGNORE, ...ignore];
|
|
97
|
+
entries = inventory.included.filter((rel) => !drop.some((g) => path.matchesGlob(rel, g)));
|
|
98
|
+
} else {
|
|
99
|
+
entries = await listFiles(root, { ignore: [...DEFAULT_IGNORE, ...ignore] });
|
|
100
|
+
}
|
|
82
101
|
const fileContents = {};
|
|
83
102
|
const depFileContents = {};
|
|
84
103
|
const oversize = [];
|
|
@@ -104,6 +123,7 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
104
123
|
else if (/\.proto$/i.test(base)) depFileContents[rel] = content;
|
|
105
124
|
else if (/\.(?:graphql|gql)$/i.test(base)) depFileContents[rel] = content;
|
|
106
125
|
else if (/\.tf$/i.test(base)) depFileContents[rel] = content;
|
|
126
|
+
else if (/^(?:BUCK|TARGETS)(?:\.v2)?$/.test(base) && /\bhaskell_|prebuilt_haskell_library/.test(content)) depFileContents[rel] = content;
|
|
107
127
|
// A Kubernetes manifest is admitted on CONTENT, not on living under a
|
|
108
128
|
// directory named k8s/ — see isKubernetesManifest. Without this the
|
|
109
129
|
// k8s-admission detector is wired into the dispatch and never invoked by it.
|
|
@@ -113,7 +133,7 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
113
133
|
// A CloudFormation template is a `.yaml`/`.json` that no path predicate can
|
|
114
134
|
// recognise — same problem as a Kubernetes manifest, same fix, and the same
|
|
115
135
|
// requirement that BOTH gates open: runFullScan re-filters this exact list.
|
|
116
|
-
if (shouldScan(rel) || isKubernetesManifest(rel, content) || isCloudFormationTemplate(rel, content) || isInstructionFile(rel)) fileContents[rel] = content;
|
|
136
|
+
if (shouldScan(rel, !!gitScope) || isKubernetesManifest(rel, content) || isCloudFormationTemplate(rel, content) || isInstructionFile(rel)) fileContents[rel] = content;
|
|
117
137
|
// Auxiliary files: .properties files are referenced by Java rules
|
|
118
138
|
// (e.g. OWASP Benchmark's benchmark.properties resolves algorithm
|
|
119
139
|
// aliases). They are not scannable for vulns themselves, but the
|
|
@@ -127,7 +147,7 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
127
147
|
const { contents } = readExplicitExports(root, manifests);
|
|
128
148
|
for (const [rel, text] of Object.entries(contents)) depFileContents[rel] = text;
|
|
129
149
|
} catch { /* exports are best-effort evidence */ }
|
|
130
|
-
return { fileContents, depFileContents, oversize };
|
|
150
|
+
return { fileContents, depFileContents, oversize, inventory };
|
|
131
151
|
}
|
|
132
152
|
|
|
133
153
|
// Feat-10: incremental scan via `--changed-since <git-ref>`. Returns the set of
|
|
@@ -174,6 +194,16 @@ export async function languageFilesIgnoredByPolicy(root, fileContents, depFileCo
|
|
|
174
194
|
return all.filter((rel) => (isLanguageSource(rel) || isLanguageManifest(rel)) && !isLanguageExcludedPath(rel) && !seen.has(rel)).sort().slice(0, 500);
|
|
175
195
|
}
|
|
176
196
|
|
|
197
|
+
// FR 02: an explicit package layout for Buck or custom builds, `{ "packages": { "name": ["dir", ...] } }`.
|
|
198
|
+
// Read as data only; nothing in the map is executed and the build is not run.
|
|
199
|
+
function loadHaskellSourceMap(file, root) {
|
|
200
|
+
if (!file) return null;
|
|
201
|
+
const abs = path.resolve(root, file);
|
|
202
|
+
const doc = JSON.parse(fsSync.readFileSync(abs, 'utf8'));
|
|
203
|
+
if (!doc || typeof doc !== 'object' || !doc.packages || typeof doc.packages !== 'object') throw new Error(`Haskell source map ${file} must be {"packages": {"<name>": ["<dir>", ...]}}`);
|
|
204
|
+
return { packages: doc.packages, file: path.relative(root, abs) };
|
|
205
|
+
}
|
|
206
|
+
|
|
177
207
|
export async function runScan(rootDir, opts = {}) {
|
|
178
208
|
const root = path.resolve(rootDir);
|
|
179
209
|
const startedAt = new Date().toISOString();
|
|
@@ -202,6 +232,7 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
202
232
|
// false claim of coverage.
|
|
203
233
|
let completeScan = true;
|
|
204
234
|
let sizeSkipped = [];
|
|
235
|
+
let scopeInventory = null;
|
|
205
236
|
if (opts.fileContents) {
|
|
206
237
|
// Caller-supplied file list (MCP `scan_diff`, the LSP's on-save scan): by
|
|
207
238
|
// construction a subset of the tree, not a scan of it.
|
|
@@ -209,7 +240,7 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
209
240
|
depFileContents = opts.depFileContents || {};
|
|
210
241
|
completeScan = false;
|
|
211
242
|
} else {
|
|
212
|
-
({ fileContents, depFileContents, oversize: sizeSkipped } = await readTree(root, opts));
|
|
243
|
+
({ fileContents, depFileContents, oversize: sizeSkipped, inventory: scopeInventory } = await readTree(root, opts));
|
|
213
244
|
}
|
|
214
245
|
|
|
215
246
|
// Haskell/Nix sources under a default-ignored directory (tests, specs, mocks, bin, build, dist, vendor) are not
|
|
@@ -254,10 +285,33 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
254
285
|
|
|
255
286
|
// R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
|
|
256
287
|
// AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
|
|
257
|
-
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan, policyExcluded, sizeSkipped }, opts.onProgress || (()=>{}));
|
|
288
|
+
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan, policyExcluded, sizeSkipped, haskellSourceMap: loadHaskellSourceMap(opts.haskellSourceMap, root), gitScope: !!scopeInventory }, opts.onProgress || (()=>{}));
|
|
258
289
|
// Premortem 2R4.2: stamp ruleset version + source on the scan result, and
|
|
259
290
|
// notify if the operator pinned a different version than what's installed.
|
|
260
291
|
try { stampScan(root, scan); } catch {}
|
|
292
|
+
// FR 01: additive scope record (never replaces an existing field). Skipped paths are
|
|
293
|
+
// capped; the counts are exact.
|
|
294
|
+
if (scopeInventory) {
|
|
295
|
+
const reasons = {};
|
|
296
|
+
for (const x of scopeInventory.skipped) reasons[x.reason] = (reasons[x.reason] || 0) + 1;
|
|
297
|
+
scan.scope = {
|
|
298
|
+
schema: 1,
|
|
299
|
+
policy: scopeInventory.policy,
|
|
300
|
+
includedCount: scopeInventory.included.length,
|
|
301
|
+
// The included list itself lets a later comparison tell "no longer reported because it was fixed" from "no longer reported
|
|
302
|
+
// because a narrower scan did not look". Kept only when small enough not to bloat the record.
|
|
303
|
+
...(scopeInventory.included.length <= 5000 ? { included: scopeInventory.included } : { includedOmitted: true }),
|
|
304
|
+
skippedCount: scopeInventory.skipped.length,
|
|
305
|
+
skippedByReason: reasons,
|
|
306
|
+
skipped: scopeInventory.skipped.slice(0, 500),
|
|
307
|
+
overrides: scopeInventory.overrides,
|
|
308
|
+
coverageConditions: scopeInventory.skipped.filter((x) => x.coverageCondition).slice(0, 100),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
// FR 10: additive evidence contract. Finding records carry the source hash and span; the scan record ties the run to a commit,
|
|
312
|
+
// dirty-file digests, the ignore-policy digest and the invocation. Neither replaces an existing field.
|
|
313
|
+
try { annotateFindingRecords(scan.findings, fileContents, { engineVersion: SCANNER_VERSION }); } catch {}
|
|
314
|
+
try { scan.scanRecord = buildScanRecord({ root, scan, meta: { mode: opts.changedSince ? 'incremental' : 'full', durationMs: Date.now() - t0 }, argv: opts.invocation || [], engineVersion: SCANNER_VERSION }); } catch {}
|
|
261
315
|
// Append snapshot to history for /security-trend (non-blocking, never throws)
|
|
262
316
|
try { appendScanSnapshot(scan, root); } catch {}
|
|
263
317
|
return {
|