@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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.
- package/CHANGELOG.md +390 -0
- package/bin/agentic-security.js +3813 -83
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +13 -4
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/2271.index.js +165 -0
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +14 -6
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +14 -6
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +67 -3
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +26 -8
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +580 -187
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/src/util/git-hardening.js +128 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
// timeout, degrading to an empty result in a non-repository.
|
|
35
35
|
|
|
36
36
|
import { execFileSync } from 'node:child_process';
|
|
37
|
+
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
37
38
|
|
|
38
39
|
const CVE_RE = /\b(?:CVE-\d{4}-\d{4,7}|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4})\b/i;
|
|
39
40
|
|
|
@@ -87,14 +88,19 @@ export function classifyCommit(subject) {
|
|
|
87
88
|
return { tier: 'mentioned', evidence: `mentions "${noun[0]}" with no fix verb` };
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
// `scanRoot` is the scanned project's repository, not this project's own
|
|
92
|
+
// trusted checkout — hardened per FR-PROV-024 / the second Finding
|
|
93
|
+
// Provenance PRD audit (same exposure class as
|
|
94
|
+
// provenance/git-evidence.js's `_run`). No `--no-textconv` needed: this
|
|
95
|
+
// invocation has no `-p`/`-L`, so no diff/blob content is rendered.
|
|
90
96
|
function _gitLog(scanRoot, { maxCommits, timeoutMs }) {
|
|
91
97
|
// NUL-delimited records so subjects containing newlines cannot split a
|
|
92
98
|
// record — a commit message is arbitrary user text and must not be able to
|
|
93
99
|
// forge a record boundary.
|
|
94
|
-
const out = execFileSync('git', [
|
|
100
|
+
const out = execFileSync('git', hardenGitArgs([
|
|
95
101
|
'-C', scanRoot, 'log', '-n', String(maxCommits), '--no-merges', '--no-color',
|
|
96
102
|
'--name-only', '--format=%x00%H%x1f%aI%x1f%s',
|
|
97
|
-
], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
103
|
+
]), { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() });
|
|
98
104
|
const commits = [];
|
|
99
105
|
for (const block of out.split('\0')) {
|
|
100
106
|
if (!block.trim()) continue;
|
package/src/pr-delta.js
CHANGED
|
@@ -24,18 +24,27 @@
|
|
|
24
24
|
// comment) can transform without re-walking IR.
|
|
25
25
|
|
|
26
26
|
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
|
|
27
28
|
import { runFullScan } from './engine.js';
|
|
28
29
|
|
|
29
30
|
const FILE_EXT_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs|py|java|cs|kt|go|rb|php|sol|swift|rs|tf|yml|yaml|json|toml|md)$/i;
|
|
30
31
|
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
31
32
|
|
|
33
|
+
// `root` is the PR's repository — a scan target, not this project's own
|
|
34
|
+
// trusted checkout — hardened per FR-PROV-024 / the second Finding
|
|
35
|
+
// Provenance PRD audit (same exposure class as
|
|
36
|
+
// provenance/git-evidence.js's `_run`).
|
|
32
37
|
function _git(root, args) {
|
|
33
|
-
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
38
|
+
const r = spawnSync('git', hardenGitArgs(args), { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: hardenGitEnv() });
|
|
34
39
|
return { ok: r.status === 0, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
function _readFileAtRef(root, ref, file) {
|
|
38
|
-
|
|
43
|
+
// `--no-textconv`: this blob-cat form of `show` (`<ref>:<file>`, not a
|
|
44
|
+
// diff) was verified NOT reachable via a hostile textconv driver in
|
|
45
|
+
// current git, same as git-evidence.js's getBlobAtCommit — kept for
|
|
46
|
+
// defense-in-depth/uniformity.
|
|
47
|
+
const r = _git(root, ['show', '--no-textconv', `${ref}:${file}`]);
|
|
39
48
|
return r.ok ? r.stdout : null;
|
|
40
49
|
}
|
|
41
50
|
|
|
@@ -56,7 +65,12 @@ async function _scanAtRef(root, ref) {
|
|
|
56
65
|
const c = _readFileAtRef(root, ref, f);
|
|
57
66
|
if (c != null) fileContents[f] = c;
|
|
58
67
|
}
|
|
59
|
-
|
|
68
|
+
// `provenance:false` — a base-ref snapshot, not the current working state.
|
|
69
|
+
// Beyond the wasted git walks, updateLifecycle marks every open stableId
|
|
70
|
+
// absent from the finding set it is handed as `remediated`; running the PR
|
|
71
|
+
// delta gate would silently rewrite the project's lifecycle store from the
|
|
72
|
+
// base ref's findings.
|
|
73
|
+
return runFullScan({ fileContents, scanRoot: root, provenance: false }, () => {});
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
function _summary(findings) {
|
|
@@ -71,7 +85,14 @@ function _summary(findings) {
|
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
function _changedFiles(root, baseRef, headRef) {
|
|
74
|
-
|
|
88
|
+
// `--no-ext-diff`: found during the second-audit-remediation sweep — this
|
|
89
|
+
// is a `git diff` call site (`--name-only`, no content rendered, so not
|
|
90
|
+
// itself a verified exploit path today, same as runScan.js's
|
|
91
|
+
// `changedSince`), but every `diff` invocation in this codebase gets it
|
|
92
|
+
// uniformly per the live exploit VERIFIED in material-change.js's
|
|
93
|
+
// classifyGitDiff (external diff drivers fire on `git diff` even with
|
|
94
|
+
// `--no-textconv`, which is a surface `--no-textconv` alone does not close).
|
|
95
|
+
const r = _git(root, ['diff', '--name-only', '--no-ext-diff', `${baseRef}...${headRef}`]);
|
|
75
96
|
if (!r.ok) return new Set();
|
|
76
97
|
return new Set(r.stdout.trim().split('\n').filter(Boolean));
|
|
77
98
|
}
|
package/src/report/index.js
CHANGED
|
@@ -5,6 +5,19 @@ import { alertFace, approveFace } from './mascot.js';
|
|
|
5
5
|
import { SCANNER_VERSION } from '../posture/version.js';
|
|
6
6
|
import { proofBlock } from '../posture/proof-artifact.js';
|
|
7
7
|
import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/legacy-compat.js';
|
|
8
|
+
// Finding Provenance (M0/M1). NOTE the name: `findingProvenance` is the
|
|
9
|
+
// git-origin record from posture/provenance/. It is NOT `finding.provenance`
|
|
10
|
+
// (posture/ai-code-fingerprint.js's AI-authorship signal, normalized a few
|
|
11
|
+
// hundred lines below) and NOT `supplyChainEntry.provenance`
|
|
12
|
+
// (sca/sigstore-verify.js's SLSA/Sigstore build attestation). Three unrelated
|
|
13
|
+
// things, three distinct keys — do not collapse them.
|
|
14
|
+
import { redactFindingProvenance, sanitizeForTerminal } from '../posture/provenance/schema.js';
|
|
15
|
+
// Re-exported: FR-PROV-026. Lives in provenance/schema.js (shared with
|
|
16
|
+
// posture/auditor-walkthrough.js, a second CLI renderer of the same
|
|
17
|
+
// untrusted fields — see that module's header for why it lives there
|
|
18
|
+
// rather than here); re-exported so `explainProvenance`'s own module keeps
|
|
19
|
+
// being the discoverable home for provenance-text callers.
|
|
20
|
+
export { sanitizeForTerminal };
|
|
8
21
|
|
|
9
22
|
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
10
23
|
const SEV_TO_SARIF = { critical: 'error', high: 'error', medium: 'warning', low: 'note', info: 'none' };
|
|
@@ -84,6 +97,62 @@ function explainParts(f, { verbose = false } = {}) {
|
|
|
84
97
|
return { why, how, fix: fix.replace(/\s+/g, ' ').trim(), fixCode };
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
// Human-readable rendering of a finding's git-origin provenance (FR-PROV
|
|
101
|
+
// output surface). Returns null when the finding carries none — a caller
|
|
102
|
+
// prints the block or omits it, and never has to know the schema.
|
|
103
|
+
//
|
|
104
|
+
// Every one of the six TERMINAL statuses gets a line of its own, and the
|
|
105
|
+
// non-`complete` ones say WHY in the status word itself rather than rendering
|
|
106
|
+
// an empty origin: that is the whole reason the status enum exists (see
|
|
107
|
+
// posture/provenance/coordinator.js's header). `Method` and `Confidence` are
|
|
108
|
+
// unconditional — a block that omitted them for a degraded status would read
|
|
109
|
+
// as "origin unknown" when the honest statement is "resolved by <method> to
|
|
110
|
+
// <confidence>". Nothing here throws on a partially-populated object; every
|
|
111
|
+
// field access is optional-chained or defaulted, because a provenance record
|
|
112
|
+
// that survived a failure path is exactly the input this has to render.
|
|
113
|
+
export function explainProvenance(f) {
|
|
114
|
+
const fp = f && f.findingProvenance;
|
|
115
|
+
if (!fp) return null;
|
|
116
|
+
const short = (v) => String(v || '').slice(0, 7);
|
|
117
|
+
const day = (v) => String(v || '').slice(0, 10);
|
|
118
|
+
const lines = [];
|
|
119
|
+
const o = fp.findingOrigin;
|
|
120
|
+
if (fp.status === 'complete' && o) {
|
|
121
|
+
lines.push(`Introduced: ${short(o.commit)} • ${day(o.authorDate)} • ${sanitizeForTerminal(o.authorName) || 'unknown'}`);
|
|
122
|
+
const bi = fp.branchIntroduction;
|
|
123
|
+
if (bi && bi.commit !== o.commit) {
|
|
124
|
+
// relationship is an internal enum ('merge'/'direct', see
|
|
125
|
+
// branch-entry.js) never sourced from untrusted git text — wrapped
|
|
126
|
+
// anyway for defense in depth and consistency with authorName above.
|
|
127
|
+
lines.push(`Branch entry: ${short(bi.commit)} • ${sanitizeForTerminal(bi.relationship) || 'unknown relationship'}`);
|
|
128
|
+
}
|
|
129
|
+
} else if (fp.status === 'partial') {
|
|
130
|
+
lines.push(`Origin: EARLIEST OBSERVABLE${o ? ' ' + short(o.commit) : ''}`);
|
|
131
|
+
} else if (fp.status === 'uncommitted') {
|
|
132
|
+
lines.push('Origin: UNCOMMITTED (working tree only)');
|
|
133
|
+
} else if (fp.status === 'not_available') {
|
|
134
|
+
lines.push('Origin: NOT AVAILABLE');
|
|
135
|
+
} else if (fp.status === 'error') {
|
|
136
|
+
lines.push('Origin: ERROR resolving provenance');
|
|
137
|
+
} else if (fp.status === 'budget_exhausted') {
|
|
138
|
+
lines.push('Origin: BUDGET EXHAUSTED before resolution completed');
|
|
139
|
+
} else {
|
|
140
|
+
// An unrecognised status is reported as itself, not silently dropped — a
|
|
141
|
+
// future status added to the enum must be visible here on the day it
|
|
142
|
+
// ships, even before this renderer learns a nicer wording for it.
|
|
143
|
+
lines.push(`Origin: ${String(fp.status || 'unknown').toUpperCase()}`);
|
|
144
|
+
}
|
|
145
|
+
if (fp.firstObserved) {
|
|
146
|
+
lines.push(`First observed: ${fp.firstObserved.scanId || 'unknown scan'} • ${day(fp.firstObserved.observedAt)}`);
|
|
147
|
+
}
|
|
148
|
+
lines.push(`Method: ${fp.method || 'none'}`);
|
|
149
|
+
lines.push(`Confidence: ${String(fp.confidence?.level || 'unknown').toUpperCase()}`);
|
|
150
|
+
if (Array.isArray(fp.limitations) && fp.limitations.length) {
|
|
151
|
+
lines.push(`Limitations: ${fp.limitations.join('; ')}`);
|
|
152
|
+
}
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
87
156
|
function fingerprint(f){
|
|
88
157
|
const s = `${f.file}:${f.line||f.source?.line||0}:${f.vuln||f.type||''}`;
|
|
89
158
|
return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
@@ -108,6 +177,24 @@ export function _remediationOf(f) {
|
|
|
108
177
|
return null;
|
|
109
178
|
}
|
|
110
179
|
|
|
180
|
+
// Redacted passthrough for the git-origin provenance record (FR-PROV output
|
|
181
|
+
// surface). Author EMAIL is PII and is withheld by default EVERYWHERE — raw
|
|
182
|
+
// JSON included, since `toJSON` derives from this same function and an email
|
|
183
|
+
// in a committed report artifact is a leak nobody opted into. Set
|
|
184
|
+
// AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL=1 to include it (read per call, not
|
|
185
|
+
// cached at module load, so a test or a wrapper can set it per invocation).
|
|
186
|
+
// `authorName` is deliberately NOT redacted by default: it is already the
|
|
187
|
+
// value the existing `introducedBy` field has carried for releases. Set
|
|
188
|
+
// AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 (--pseudonymize-authors) to replace
|
|
189
|
+
// it with a stable Contributor-XXXXXXXX id instead — PRD Section 8.
|
|
190
|
+
function _normalizedProvenance(f) {
|
|
191
|
+
if (!f || !f.findingProvenance) return null;
|
|
192
|
+
return redactFindingProvenance(f.findingProvenance, {
|
|
193
|
+
includeEmail: process.env.AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL === '1',
|
|
194
|
+
pseudonymize: process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
111
198
|
export function normalizeFindings(scan){
|
|
112
199
|
const out = [];
|
|
113
200
|
// Feat-4: filter findings via custom suppressions, recording the suppression
|
|
@@ -250,6 +337,10 @@ export function normalizeFindings(scan){
|
|
|
250
337
|
cloneClusterSize: typeof f.cloneClusterSize === 'number' ? f.cloneClusterSize : null,
|
|
251
338
|
provenance: f.provenance || null,
|
|
252
339
|
provenanceScore: typeof f.provenanceScore === 'number' ? f.provenanceScore : null,
|
|
340
|
+
// posture/provenance/coordinator.js#annotateGitProvenance — WHICH COMMIT
|
|
341
|
+
// introduced this finding. A different question, and a different field,
|
|
342
|
+
// from the two lines above it (see the import comment at the top).
|
|
343
|
+
findingProvenance: _normalizedProvenance(f),
|
|
253
344
|
typeNarrowed: f.typeNarrowed || null,
|
|
254
345
|
strideCategory: f.strideCategory || null,
|
|
255
346
|
personaScores: f.personaScores || null,
|
|
@@ -349,6 +440,15 @@ export function normalizeFindings(scan){
|
|
|
349
440
|
commit: s.commit || null,
|
|
350
441
|
historical: s._historical === true,
|
|
351
442
|
description: s.description || null,
|
|
443
|
+
// Second independent Finding Provenance PRD audit (Task 7, item 4):
|
|
444
|
+
// `stableId` was emitted for the SAST channel only (below), so a
|
|
445
|
+
// consumer of a secrets/logic/SCA finding could not recompute
|
|
446
|
+
// `findingProvenance.evidenceDigest` (it binds `stableId` as its first
|
|
447
|
+
// input — coordinator.js's `computeDigest`) — the exact case this
|
|
448
|
+
// channel needs it for, since `annotateStableIds(aSecrets)` (engine.js)
|
|
449
|
+
// already backfills a real one before provenance resolution runs.
|
|
450
|
+
stableId: s.stableId || null,
|
|
451
|
+
findingProvenance: _normalizedProvenance(s),
|
|
352
452
|
});
|
|
353
453
|
}
|
|
354
454
|
for (const lv of (scan.logicVulns||[])) {
|
|
@@ -376,6 +476,15 @@ export function normalizeFindings(scan){
|
|
|
376
476
|
ecosystem: lv.ecosystem || null,
|
|
377
477
|
license: lv.license || null,
|
|
378
478
|
description: lv.description || null,
|
|
479
|
+
// Second independent Finding Provenance PRD audit (Task 7, item 4) —
|
|
480
|
+
// same reasoning as the secrets channel above. Note: only the
|
|
481
|
+
// `blameableLogic` subset (engine.js) gets a real backfilled stableId
|
|
482
|
+
// via `annotateStableIds`; the three synthetic-line producers
|
|
483
|
+
// (license-policy:/deploy-platform:/stack-playbook:) never go through
|
|
484
|
+
// that backfill and stay `null` here — honest, since they also stay on
|
|
485
|
+
// the permanent not_available provenance path (posture/CLAUDE.md).
|
|
486
|
+
stableId: lv.stableId || null,
|
|
487
|
+
findingProvenance: _normalizedProvenance(lv),
|
|
379
488
|
});
|
|
380
489
|
}
|
|
381
490
|
for (const sc of (scan.supplyChain||[])) {
|
|
@@ -425,6 +534,20 @@ export function normalizeFindings(scan){
|
|
|
425
534
|
toxicity: sc.toxicityScore ?? null,
|
|
426
535
|
toxicityFactors: sc.toxicityFactors || null,
|
|
427
536
|
toxicityLabel: sc.toxicityLabel || null,
|
|
537
|
+
// Git-origin provenance for the dependency declaration (which commit
|
|
538
|
+
// moved the declared version into the advisory's vulnerable range).
|
|
539
|
+
// Distinct from `sc.provenance`, which sca/sigstore-verify.js uses for
|
|
540
|
+
// the package's Sigstore/SLSA build attestation — that one is NOT
|
|
541
|
+
// carried here today and must not be conflated with this field.
|
|
542
|
+
// Second independent Finding Provenance PRD audit (Task 7, item 4) —
|
|
543
|
+
// same reasoning as the secrets/logic channels above. Only entries
|
|
544
|
+
// routed through annotateGitProvenance (engine.js's `directDeps`/
|
|
545
|
+
// `transitiveDeps`, `isSca`/`isTransitiveSca` in coordinator.js) get a
|
|
546
|
+
// real backfilled `scaStableId`; an entry that was never routed
|
|
547
|
+
// through it (e.g. `unpinned_dep`/`no_lockfile` types) honestly stays
|
|
548
|
+
// `null` here rather than fabricating one.
|
|
549
|
+
stableId: sc.stableId || null,
|
|
550
|
+
findingProvenance: _normalizedProvenance(sc),
|
|
428
551
|
});
|
|
429
552
|
}
|
|
430
553
|
// FR-108: backfill deprecated field names from their current replacements
|
|
@@ -676,15 +799,22 @@ export function toCSV(scan){
|
|
|
676
799
|
const s = String(v);
|
|
677
800
|
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
678
801
|
};
|
|
679
|
-
|
|
802
|
+
// FR-PROV-018: a nested findingProvenance object has no natural CSV
|
|
803
|
+
// representation, so this is a deliberate flattening (status/commit/
|
|
804
|
+
// authorDate/confidence), not full fidelity. Appended after the existing
|
|
805
|
+
// columns so a spreadsheet already keyed on column position is unaffected.
|
|
806
|
+
const header = ['id', 'severity', 'vuln', 'cwe', 'cvss', 'owasp', 'file', 'line', 'confidence', 'reachable', 'kind', 'snippet', 'provenanceStatus', 'provenanceCommit', 'provenanceAuthorDate', 'provenanceConfidence'];
|
|
680
807
|
const rows = [header.join(',')];
|
|
681
808
|
for (const f of findings) {
|
|
809
|
+
const fp = f.findingProvenance;
|
|
682
810
|
rows.push([
|
|
683
811
|
esc(f.id), esc(f.severity), esc(f.vuln), esc(f.cwe), esc(f.cvss || ''),
|
|
684
812
|
esc(f.owasp || ''), esc(f.file), esc(f.line),
|
|
685
813
|
esc(f.confidence == null ? '' : f.confidence.toFixed(3)),
|
|
686
814
|
esc(f.reachable == null ? '' : f.reachable),
|
|
687
815
|
esc(f.kind), esc((f.snippet || '').slice(0, 200)),
|
|
816
|
+
esc(fp?.status || ''), esc(fp?.findingOrigin?.commit || ''),
|
|
817
|
+
esc(fp?.findingOrigin?.authorDate || ''), esc(fp?.confidence?.level || ''),
|
|
688
818
|
].join(','));
|
|
689
819
|
}
|
|
690
820
|
return rows.join('\n');
|
|
@@ -732,6 +862,15 @@ export function toJUnit(scan, meta={}){
|
|
|
732
862
|
return lines.join('\n');
|
|
733
863
|
}
|
|
734
864
|
|
|
865
|
+
// FR-PROV-026: length of the Markdown code fence needed to safely wrap
|
|
866
|
+
// `text` without it being able to break out via an embedded backtick run
|
|
867
|
+
// (e.g. from an unsanitized-for-backticks authorName). Minimum 3, per
|
|
868
|
+
// CommonMark; longer only when `text` itself contains a run that long.
|
|
869
|
+
function _mdFenceLen(text) {
|
|
870
|
+
const runs = String(text == null ? '' : text).match(/`+/g) || [];
|
|
871
|
+
return Math.max(3, ...runs.map(r => r.length + 1));
|
|
872
|
+
}
|
|
873
|
+
|
|
735
874
|
export function toMarkdown(scan, meta={}){
|
|
736
875
|
const findings = normalizeFindings(scan);
|
|
737
876
|
const lines = ['# Agentic Security — Scan Report', ''];
|
|
@@ -764,6 +903,32 @@ export function toMarkdown(scan, meta={}){
|
|
|
764
903
|
lines.push(`| \`${f.file}:${f.line}\` | ${f.vuln} | ${f.cwe||'—'} | ${epss} | ${fix.replace(/\|/g,'\\|').slice(0,140)} |`);
|
|
765
904
|
}
|
|
766
905
|
}
|
|
906
|
+
// FR-PROV-018: one provenance block per finding that has one, reusing
|
|
907
|
+
// explainProvenance's content — never a second, divergent renderer.
|
|
908
|
+
const withProvenance = bySev[sev].filter(f => f.findingProvenance);
|
|
909
|
+
if (withProvenance.length) {
|
|
910
|
+
lines.push('');
|
|
911
|
+
lines.push('<details><summary>Provenance</summary>');
|
|
912
|
+
lines.push('');
|
|
913
|
+
for (const f of withProvenance) {
|
|
914
|
+
const block = explainProvenance(f);
|
|
915
|
+
if (!block) continue;
|
|
916
|
+
lines.push(`**\`${f.file}:${f.line}\`** — ${f.vuln}`);
|
|
917
|
+
// FR-PROV-026: sanitizeForTerminal (applied inside explainProvenance)
|
|
918
|
+
// strips control chars but NOT backticks, which are ordinary text —
|
|
919
|
+
// a malicious authorName containing ``` could otherwise break out of
|
|
920
|
+
// a fixed 3-backtick fence and inject raw Markdown/HTML into the
|
|
921
|
+
// report. Use a fence one backtick longer than any run already in
|
|
922
|
+
// the block (CommonMark's own escaping mechanism for fenced code),
|
|
923
|
+
// so normal content (never containing backticks) is unaffected.
|
|
924
|
+
const fence = '`'.repeat(_mdFenceLen(block));
|
|
925
|
+
lines.push(fence);
|
|
926
|
+
lines.push(block);
|
|
927
|
+
lines.push(fence);
|
|
928
|
+
lines.push('');
|
|
929
|
+
}
|
|
930
|
+
lines.push('</details>');
|
|
931
|
+
}
|
|
767
932
|
lines.push('');
|
|
768
933
|
}
|
|
769
934
|
return lines.join('\n');
|
|
@@ -841,6 +1006,16 @@ export function toSARIF(scan, meta={}){
|
|
|
841
1006
|
...(scan && scan._rulesetVersion ? { rulesetVersion: scan._rulesetVersion } : {}),
|
|
842
1007
|
...(scan && scan._rulesetVersionSource ? { rulesetVersionSource: scan._rulesetVersionSource } : {}),
|
|
843
1008
|
...(scan && scan._rulesetVersionMismatch ? { rulesetVersionMismatch: scan._rulesetVersionMismatch } : {}),
|
|
1009
|
+
// FR-PROV-018: run-level provenance summary — how many results
|
|
1010
|
+
// resolved which terminal status, so a SARIF consumer can judge
|
|
1011
|
+
// history coverage without walking every result's properties.
|
|
1012
|
+
...(findings.some(f => f.findingProvenance) ? {
|
|
1013
|
+
provenanceCoverage: findings.reduce((acc, f) => {
|
|
1014
|
+
const s = f.findingProvenance?.status || 'none';
|
|
1015
|
+
acc[s] = (acc[s] || 0) + 1;
|
|
1016
|
+
return acc;
|
|
1017
|
+
}, {}),
|
|
1018
|
+
} : {}),
|
|
844
1019
|
},
|
|
845
1020
|
}],
|
|
846
1021
|
results: findings.map(f => {
|
|
@@ -905,6 +1080,12 @@ export function toSARIF(scan, meta={}){
|
|
|
905
1080
|
signatureStatus: f.signatureStatus || (f._passThroughSigning ? 'pass-through' : (f._unsigned ? 'unsigned' : 'verified')),
|
|
906
1081
|
...(f._unsigned ? { unsigned: true } : {}),
|
|
907
1082
|
...(f._passThroughSigning ? { passThroughSigning: true } : {}),
|
|
1083
|
+
// FR-PROV-018: `f` is already normalized (findingProvenance already
|
|
1084
|
+
// passed through _normalizedProvenance/redactFindingProvenance by
|
|
1085
|
+
// normalizeFindings), so this is a redacted passthrough, never a
|
|
1086
|
+
// second redaction pass and never a read of a raw pre-normalization
|
|
1087
|
+
// finding.
|
|
1088
|
+
...(f.findingProvenance ? { findingProvenance: f.findingProvenance } : {}),
|
|
908
1089
|
},
|
|
909
1090
|
};}),
|
|
910
1091
|
}],
|
|
@@ -966,7 +1147,7 @@ export function toHTML(scan, meta = {}) {
|
|
|
966
1147
|
// server-side) so the browser render shows them without a second command.
|
|
967
1148
|
const findings = normalizeFindings(scan).map(f => {
|
|
968
1149
|
const ex = explainParts(f, { verbose: true });
|
|
969
|
-
return { ...f, _riskNote: riskNote(f), _explainWhy: ex.why, _explainHow: ex.how };
|
|
1150
|
+
return { ...f, _riskNote: riskNote(f), _explainWhy: ex.why, _explainHow: ex.how, _explainProvenance: explainProvenance(f) };
|
|
970
1151
|
});
|
|
971
1152
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
972
1153
|
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
@@ -1044,6 +1225,8 @@ export function toHTML(scan, meta = {}) {
|
|
|
1044
1225
|
.f-how{margin-top:6px;color:#94a3b8;font-size:13px}
|
|
1045
1226
|
.f-how code{font-family:ui-monospace,monospace;color:#e2e8f4}
|
|
1046
1227
|
.f-fix{background:#0d1f3d;border-left:3px solid #38bdf8;padding:8px 12px;margin-top:8px;border-radius:0 4px 4px 0}
|
|
1228
|
+
.f-provenance{background:#0f1f14;border-left:3px solid #34d058;padding:8px 12px;margin-top:8px;border-radius:0 4px 4px 0}
|
|
1229
|
+
.f-provenance pre{background:transparent;padding:0;margin:4px 0 0 0}
|
|
1047
1230
|
.hidden{display:none!important}
|
|
1048
1231
|
</style></head>
|
|
1049
1232
|
<body>
|
|
@@ -1099,6 +1282,7 @@ function makeCard(f) {
|
|
|
1099
1282
|
(f.snippet ? '<pre>' + esc(f.snippet) + '</pre>' : '') +
|
|
1100
1283
|
(f.masked ? '<pre style="color:#f97316">' + esc(f.masked) + ' (masked)</pre>' : '') +
|
|
1101
1284
|
(f.fix && f.fix.description ? '<div class="f-fix"><b>Fix:</b> ' + esc(f.fix.description) + (f.fix.code ? '<pre>' + esc(f.fix.code) + '</pre>' : '') + '</div>' : '') +
|
|
1285
|
+
(f._explainProvenance ? '<div class="f-provenance"><b>Provenance:</b><pre>' + esc(f._explainProvenance) + '</pre></div>' : '') +
|
|
1102
1286
|
'</div>';
|
|
1103
1287
|
div.addEventListener('click', () => div.classList.toggle('expanded'));
|
|
1104
1288
|
return div;
|
|
@@ -1178,7 +1362,10 @@ const RESET = '\x1b[0m';
|
|
|
1178
1362
|
const DIM = '\x1b[2m';
|
|
1179
1363
|
const BOLD = '\x1b[1m';
|
|
1180
1364
|
|
|
1181
|
-
|
|
1365
|
+
// `provenance` defaults to FALSE on purpose: the block is five-plus extra
|
|
1366
|
+
// lines per finding, and the default CLI listing is already dense. It prints
|
|
1367
|
+
// only when the operator asks for it.
|
|
1368
|
+
export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
|
|
1182
1369
|
const findings = normalizeFindings(scan);
|
|
1183
1370
|
const lines = [];
|
|
1184
1371
|
const c = (s, code) => color ? `${code}${s}${RESET}` : s;
|
|
@@ -1204,6 +1391,13 @@ export function toCLI(scan, { verbose=false, color=true }={}){
|
|
|
1204
1391
|
if (ex.how) lines.push(` ${c('how:', DIM)} ${ex.how}`);
|
|
1205
1392
|
if (ex.fix) lines.push(` ${c('fix:', DIM)} ${ex.fix}`);
|
|
1206
1393
|
if (ex.fixCode) for (const ln of ex.fixCode.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
|
|
1394
|
+
if (provenance) {
|
|
1395
|
+
// `f` here is already normalized, so its findingProvenance has been
|
|
1396
|
+
// through redactFindingProvenance — the email is gone before it can
|
|
1397
|
+
// reach a terminal.
|
|
1398
|
+
const prov = explainProvenance(f);
|
|
1399
|
+
if (prov) for (const ln of prov.split('\n')) lines.push(` ${c(ln, DIM)}`);
|
|
1400
|
+
}
|
|
1207
1401
|
}
|
|
1208
1402
|
lines.push('');
|
|
1209
1403
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
package/src/runScan.js
CHANGED
|
@@ -4,6 +4,7 @@ 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
6
|
import { listFiles } from './util/glob.js';
|
|
7
|
+
import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
|
|
7
8
|
import { runFullScan, shouldScan, isKubernetesManifest, isCloudFormationTemplate, isInstructionFile } from './engine.js';
|
|
8
9
|
import { appendScanSnapshot } from './posture/security-trend.js';
|
|
9
10
|
import { recover as recoverFixHistory } from './posture/fix-history.js';
|
|
@@ -111,17 +112,28 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
111
112
|
|
|
112
113
|
// Feat-10: incremental scan via `--changed-since <git-ref>`. Returns the set of
|
|
113
114
|
// repo-relative paths modified since the ref, or null if git is unavailable.
|
|
115
|
+
//
|
|
116
|
+
// `root` is the scan target's repository, not this project's own trusted
|
|
117
|
+
// checkout — hardened per FR-PROV-024 / the second Finding Provenance PRD
|
|
118
|
+
// audit (same exposure class as provenance/git-evidence.js: a hostile
|
|
119
|
+
// .git/config's `core.fsmonitor` fires on the `git status --porcelain`
|
|
120
|
+
// call below just from reading repo state). `--no-ext-diff` is added to the
|
|
121
|
+
// `diff --name-only` call too: VERIFIED that shape does not itself invoke an
|
|
122
|
+
// external diff driver in current git (no content is rendered), but it costs
|
|
123
|
+
// nothing and keeps every `git diff` call site in this codebase uniformly
|
|
124
|
+
// hardened against the surface `material-change.js`'s `classifyGitDiff` was
|
|
125
|
+
// actually caught on (see that file's comment for the live exploit).
|
|
114
126
|
export function changedSince(root, gitRef) {
|
|
115
127
|
if (!gitRef) return null;
|
|
116
128
|
try {
|
|
117
|
-
const out = cp.execFileSync('git', ['diff', '--name-only', `${gitRef}...HEAD`], {
|
|
118
|
-
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
129
|
+
const out = cp.execFileSync('git', hardenGitArgs(['diff', '--name-only', '--no-ext-diff', `${gitRef}...HEAD`]), {
|
|
130
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv(),
|
|
119
131
|
});
|
|
120
132
|
const set = new Set(out.split('\n').filter(Boolean));
|
|
121
133
|
// Also include uncommitted changes
|
|
122
134
|
try {
|
|
123
|
-
const dirty = cp.execFileSync('git', ['status', '--porcelain'], {
|
|
124
|
-
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
135
|
+
const dirty = cp.execFileSync('git', hardenGitArgs(['status', '--porcelain']), {
|
|
136
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv(),
|
|
125
137
|
});
|
|
126
138
|
for (const line of dirty.split('\n')) {
|
|
127
139
|
const f = line.slice(3).trim();
|
|
@@ -151,9 +163,22 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
151
163
|
// Caller may pre-build fileContents (used by the MCP server's scan_diff to
|
|
152
164
|
// scope a scan to a specific file list without walking the whole tree).
|
|
153
165
|
let fileContents, depFileContents;
|
|
166
|
+
// `completeScan` answers ONE question for the whole pipeline: does the file
|
|
167
|
+
// set below cover all of `root`, or only a subset of it? Anything downstream
|
|
168
|
+
// that reasons about the ABSENCE of a finding — most importantly the
|
|
169
|
+
// provenance lifecycle ledger, whose remediation pass closes every open
|
|
170
|
+
// stableId missing from this scan — is only sound on a complete scan. A
|
|
171
|
+
// subset scan that claims completeness marks the entire rest of the project
|
|
172
|
+
// remediated. It starts true and is only ever narrowed, so a new subsetting
|
|
173
|
+
// path added later must opt OUT explicitly rather than silently inherit a
|
|
174
|
+
// false claim of coverage.
|
|
175
|
+
let completeScan = true;
|
|
154
176
|
if (opts.fileContents) {
|
|
177
|
+
// Caller-supplied file list (MCP `scan_diff`, the LSP's on-save scan): by
|
|
178
|
+
// construction a subset of the tree, not a scan of it.
|
|
155
179
|
fileContents = opts.fileContents;
|
|
156
180
|
depFileContents = opts.depFileContents || {};
|
|
181
|
+
completeScan = false;
|
|
157
182
|
} else {
|
|
158
183
|
({ fileContents, depFileContents } = await readTree(root, opts));
|
|
159
184
|
}
|
|
@@ -167,6 +192,10 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
167
192
|
if (changed.has(f)) filtered[f] = fileContents[f];
|
|
168
193
|
}
|
|
169
194
|
fileContents = filtered;
|
|
195
|
+
// Only when the filter actually applied. A `changedSince` that resolved
|
|
196
|
+
// to null (not a git repo / bad ref) is warned about below and scans the
|
|
197
|
+
// whole tree, which IS complete.
|
|
198
|
+
completeScan = false;
|
|
170
199
|
} else if (opts.onProgress) {
|
|
171
200
|
opts.onProgress({ phase: 'warning', file: 'changedSince ignored: not a git repo or invalid ref', current: 0, total: 0 });
|
|
172
201
|
}
|
|
@@ -174,7 +203,7 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
174
203
|
|
|
175
204
|
// R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
|
|
176
205
|
// AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
|
|
177
|
-
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi }, opts.onProgress || (()=>{}));
|
|
206
|
+
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan }, opts.onProgress || (()=>{}));
|
|
178
207
|
// Premortem 2R4.2: stamp ruleset version + source on the scan result, and
|
|
179
208
|
// notify if the operator pinned a different version than what's installed.
|
|
180
209
|
try { stampScan(root, scan); } catch {}
|
package/src/sast/rate-limit.js
CHANGED
|
@@ -31,7 +31,13 @@ const AI_PATH_RE = /\/(?:ai|chat|generate|complete|completion|embed|embedding|gp
|
|
|
31
31
|
const PAYMENT_PATH_RE = /\/(?:pay(?:ment)?|checkout|stripe|order|subscribe|billing|invoice|charge|purchase)\b/i;
|
|
32
32
|
const CONTACT_PATH_RE = /\/(?:contact|submit|feedback|form|newsletter|subscribe|waitlist|signup|onboard)\b/i;
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// Exported (FR-PROV-017) so posture/provenance/coordinator.js can reuse the
|
|
35
|
+
// EXACT SAME presence test as a `resolveMissingControl` predicate: "did a
|
|
36
|
+
// commit's historical blob have rate limiting" must be answered by the same
|
|
37
|
+
// logic that decides "does HEAD have rate limiting", or a drift between the
|
|
38
|
+
// two could fabricate a regression (or miss a real one) that only existed in
|
|
39
|
+
// the predicate's own disagreement with the detector, not in the code.
|
|
40
|
+
function hasRateLimit(content) {
|
|
35
41
|
return RL_IMPORT_RE.test(content) || RL_USAGE_RE.test(content) || REDIS_RL_RE.test(content);
|
|
36
42
|
}
|
|
37
43
|
|
|
@@ -77,7 +83,7 @@ const CATEGORY_META = {
|
|
|
77
83
|
function scanRateLimit(file, content) {
|
|
78
84
|
if (!_SCAN_EXT_RE.test(file)) return [];
|
|
79
85
|
if (_NONPROD_RE.test(file)) return [];
|
|
80
|
-
if (
|
|
86
|
+
if (hasRateLimit(content)) return [];
|
|
81
87
|
const findings = [];
|
|
82
88
|
const lines = content.split('\n');
|
|
83
89
|
|
|
@@ -99,6 +105,18 @@ function scanRateLimit(file, content) {
|
|
|
99
105
|
description: meta.description,
|
|
100
106
|
remediation: meta.remediation,
|
|
101
107
|
cwe: meta.cwe,
|
|
108
|
+
// FR-PROV-017: routes posture/provenance/coordinator.js to
|
|
109
|
+
// resolveMissingControl instead of the plain SAST origin-resolver.
|
|
110
|
+
// "A route lacks rate limiting" is exactly the "was this control
|
|
111
|
+
// ever present, and if so when did it disappear" question that
|
|
112
|
+
// resolver answers — a plain SAST resolver would instead ask "when
|
|
113
|
+
// was this LINE introduced," which is the wrong question for a
|
|
114
|
+
// finding about something ABSENT. An explicit boolean marker set
|
|
115
|
+
// here (rather than coordinator.js string-matching finding.id/vuln)
|
|
116
|
+
// keeps the two modules' string formats decoupled, matching how
|
|
117
|
+
// isDirect/isTransitiveSca-style markers already route elsewhere in
|
|
118
|
+
// this pipeline.
|
|
119
|
+
missingControlCandidate: true,
|
|
102
120
|
});
|
|
103
121
|
}
|
|
104
122
|
}
|
|
@@ -120,6 +138,18 @@ function scanRateLimit(file, content) {
|
|
|
120
138
|
description: meta.description,
|
|
121
139
|
remediation: meta.remediation,
|
|
122
140
|
cwe: meta.cwe,
|
|
141
|
+
// FR-PROV-017: routes posture/provenance/coordinator.js to
|
|
142
|
+
// resolveMissingControl instead of the plain SAST origin-resolver.
|
|
143
|
+
// "A route lacks rate limiting" is exactly the "was this control
|
|
144
|
+
// ever present, and if so when did it disappear" question that
|
|
145
|
+
// resolver answers — a plain SAST resolver would instead ask "when
|
|
146
|
+
// was this LINE introduced," which is the wrong question for a
|
|
147
|
+
// finding about something ABSENT. An explicit boolean marker set
|
|
148
|
+
// here (rather than coordinator.js string-matching finding.id/vuln)
|
|
149
|
+
// keeps the two modules' string formats decoupled, matching how
|
|
150
|
+
// isDirect/isTransitiveSca-style markers already route elsewhere in
|
|
151
|
+
// this pipeline.
|
|
152
|
+
missingControlCandidate: true,
|
|
123
153
|
});
|
|
124
154
|
}
|
|
125
155
|
}
|
|
@@ -127,4 +157,4 @@ function scanRateLimit(file, content) {
|
|
|
127
157
|
return findings;
|
|
128
158
|
}
|
|
129
159
|
|
|
130
|
-
export { scanRateLimit };
|
|
160
|
+
export { scanRateLimit, hasRateLimit };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# scanner/src/server/
|
|
2
|
+
|
|
3
|
+
The `agentic-security explore` local server — Milestone 3 of the Data
|
|
4
|
+
Flow Explorer PRD ("local server and secure API," PRD §17). Read-only in
|
|
5
|
+
this increment; serves the already-scanned, already-signed
|
|
6
|
+
`DataFlowGraph v1` artifact over a hardened loopback HTTP API.
|
|
7
|
+
|
|
8
|
+
## What's here
|
|
9
|
+
|
|
10
|
+
**Sub-project Server, increment 1 (read-only GET endpoints + full
|
|
11
|
+
security posture) — COMPLETE:**
|
|
12
|
+
|
|
13
|
+
| Module | Responsibility |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `docs/superpowers/plans/2026-09-01-data-flow-explorer-m3-server-scoping.md` + `…-s1-plan.md` | Confirms the entire deliverable was greenfield (no HTTP server code anywhere; the MCP server is JSON-RPC-over-stdio, the LSP server a different stdio protocol — neither has an HTTP surface). Scopes S1 to the five P0 GET endpoints only, deferring `query`/`export` (real design work of their own) to S2/M4. Every §17.4 security bullet is a first-class S1 requirement, not a follow-up pass, since `docs/DATA_FLOW_EXPLORER_THREAT_MODEL.md`'s own T2 (DNS rebinding)/T3 (CSRF)/T4 (path traversal) entries are explicitly gated on "the server does not exist yet." |
|
|
16
|
+
| `graph-loader.js` | `loadSignedGraph(scanRoot)` — reads `.agentic-security/lineage-graph.json` + `.sig`, reusing `posture/integrity.js`'s `verifyLastScan` DIRECTLY (never a second, parallel verification mechanism — that function already uses `crypto.timingSafeEqual` internally). Four distinct, clearly-messaged failure reasons: `missing` (no file), `unsigned` (`.sig` absent), `tampered` (signature mismatch), `malformed` (valid signature, invalid JSON). Loaded once at server startup, held in memory — this is a read-only, single-scan-snapshot server; a change to the graph on disk mid-session is out of scope (the threat model doc's own "P0 is read-only" framing). |
|
|
17
|
+
| `security.js` | `generateSessionToken()` (32 random bytes, hex); `constantTimeEqual(a, b)` (wraps `crypto.timingSafeEqual`, length-guarded the same way `verifyLastScan` guards its own HMAC comparison — never throws on mismatched-length input); `isValidHost(hostHeader, expectedPort)` — the T2 DNS-rebinding defense, accepting only `127.0.0.1`/`localhost`/`::1` (bare or bracketed) at the exact expected port, rejecting everything else including alternate-encoded loopback forms (decimal/octal/hex IP literals) — a hostile page's own Host header carries ITS OWN origin, never `127.0.0.1`, so this check defeats DNS rebinding regardless of browser same-origin-policy quirks; `CSP_HEADER_VALUE = "default-src 'none'; frame-ancestors 'none'"` (maximally restrictive — this server serves JSON only in S1, no HTML/inline script surface at all). |
|
|
18
|
+
| `routes.js` | Six PURE handler functions, each `(graph, ...) -> {status, body}` — `handleScan`, `handleGraph`, `handleNode`, `handleEdge`, `handleFlow`, `handleQuery` — no `req`/`res` access anywhere in this file, independently unit-testable without an HTTP layer. `wrapResponse(data, graph, {canonicalIds})` wraps every body in the exact envelope PRD line 1326 requires ("base graph/snapshot digest, schema/extension versions, scope, coverage, limitations, and contributing canonical IDs"), mapped onto the graph's own real fields (`graphId`→digest, `schemaVersion`, `extensions`, `scope`, `coverage`, `limitations`). `canonicalIds` is `null` for `handleScan`/`handleGraph`/`handleQuery` (all three describe the whole graph or a filtered projection of it — `handleGraph`'s own body already IS the full node/edge/flow arrays, so re-listing every id would be pure duplication with real cost on a large graph), `[id]` for `handleNode`/`handleEdge`, and the flow's own id plus its `source`/`sink`/`edgeIds` for `handleFlow` (a derived record referencing several other entities — naming them is genuinely useful metadata, not duplication). **`handleQuery(graph, filter)`** (Milestone 5, large-graph pagination — the `POST /api/v1/query` endpoint the S1-era header comment above and this file's own "What this does NOT do" section used to defer) shape-validates `filter` via `export-json.js`'s `validateFilterShape` (400 on a malformed filter, never a thrown exception) and, when valid, narrows the graph via that same module's already-shipped, already-tested `_filterGraph` — the identical `{nodeIds, edgeIds}` shape `dataflow export --filter` and the MCP `dataflow_get_graph` tool already use, reused rather than reimplemented. `undefined`/`{}` returns the whole graph, identical to `handleGraph`. |
|
|
19
|
+
| `http-server.js` | `createExploreServer({graph, port, sessionToken, idleTimeoutMs, keepOpen})` — the first `node:http` usage anywhere in `scanner/src/`. Binds explicitly to `127.0.0.1` (never `0.0.0.0`, never an omitted host argument — that is NOT the same as `127.0.0.1` on every platform). Every request: `Host` header validated (T2, 400 on failure) BEFORE the session token (T3, 401 on failure, `constantTimeEqual`) BEFORE a uniform request-size cap (`MAX_REQUEST_BODY_BYTES = 64KB`, 413 on excess — S1's GET endpoints have no meaningful body; Milestone 5's `POST /api/v1/query` is the first real consumer of the body-accumulation this cap now gates) BEFORE routing. `POST /api/v1/query`'s request body is accumulated alongside the pre-existing size-cap check and JSON-parsed only for a matched route with a non-empty body (malformed JSON → 400, never a 500) — every pre-M5 GET route still ignores the handler's 3rd `body` argument entirely, so an empty/missing body for them stays a no-op, unchanged. Every response: `Content-Security-Policy` set, `Cache-Control: no-store` set, `Access-Control-Allow-Origin` NEVER set anywhere in this file (CORS stays disabled by the header's own absence — do not add a permissive default later without re-reading this line). Idle-timeout auto-stop (`DEFAULT_IDLE_TIMEOUT_MS = 30 min`, no PRD-specified number exists, a disclosed reasoned default) resets ONLY on requests that pass BOTH the Host and token checks — an unauthenticated prober (a DNS-rebinding attempt, a stray scan) can never keep the server alive by hammering it; only genuine authenticated activity extends the session. `keepOpen: true` suppresses auto-stop entirely. Logging is metadata-only (method/path/status/duration) — never response bodies, never the session token, even on a failed-auth attempt. A 500 from a route handler returns a generic `{error: 'internal error'}` body, never the underlying exception's message/stack. **Disclosed, non-security gap**: no `SIGINT`/`SIGTERM` handler exists yet, so Ctrl-C relies on Node's default abrupt-exit rather than a graceful `server.close()` — functionally fine (the process does exit) but not maximally graceful; a future increment could add one for a cleaner shutdown message. |
|
|
20
|
+
| `bin/agentic-security.js` (extended, additively) | `case 'explore':` + `cmdExplore(args)` — parses the scan root (`args._[1]`, default cwd), `--port` (validated: digits-only, ≤65535), `--keep-open`. Calls `loadSignedGraph` FIRST; on any failure, prints the specific clear message and exits non-zero WITHOUT ever starting the server (never binds a port, never generates a token, on a missing/tampered/unsigned/malformed graph). On success, generates a fresh session token per invocation and prints it to stdout EXACTLY ONCE — the only place it is ever displayed, never written to a file, never logged again after this one print. Returns a Promise resolving to exit code 0 when the server eventually closes (idle-timeout or an external kill), matching every other `cmdX(args)` function's own "returns a numeric exit code" contract while still supporting a long-running process. `explore` never triggers a scan itself — it requires a pre-existing artifact, matching §17.3's own "P0 is read-only with respect to source" framing exactly. |
|
|
21
|
+
| `test/server/*.test.js` (41 tests) | `security.test.js`/`graph-loader.test.js`/`routes.test.js` — pure-function unit tests (isolated `constantTimeEqual`/`isValidHost` edge cases; all four `loadSignedGraph` failure reasons via a real signed/tampered/unsigned/malformed fixture; all five route handlers' shapes). `http-server.test.js` — REAL end-to-end `node:http.request` calls against a REAL running server (never a mocked/isolated unit test for the security-critical properties): a forged `Host` header rejected live (T2); a missing AND a wrong session token rejected live on every one of the five endpoints (T3); response headers asserted present/absent on BOTH a 200 and a rejected response; a real short-idle-timeout variant proving the server genuinely stops listening and refuses new connections (`ECONNREFUSED`), and a `keepOpen: true` variant proving it doesn't; an oversized-body 413. `cmd-explore.test.js` — the real spawned CLI end to end (missing graph → non-zero exit, no token ever printed, connection refused; a valid signed graph → a real server whose printed URL + token authenticate a real request). Independently re-verified by the coordinator via a THIRD path beyond the implementer's own tests: a real `explore` invocation launched manually, hit with `curl` from outside any test harness, confirming the same 200/401/400 outcomes and headers live. |
|
|
22
|
+
|
|
23
|
+
**Sub-project Wire (live-API wiring for the frontend) — COMPLETE:**
|
|
24
|
+
|
|
25
|
+
| Module | Responsibility |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `docs/superpowers/plans/2026-09-01-data-flow-explorer-m3-wire-scoping.md` + `…-plan.md` | The load-bearing correction this increment starts from: §17.4's CORS-disabled-by-default posture means the frontend MUST be served from the SAME origin as the API, or the browser blocks every `fetch()` regardless of what the server returns. Decides the session token travels via a URL FRAGMENT (`#token=...`), never a query string or cookie — a fragment is never sent to the server in any HTTP request. |
|
|
28
|
+
| `static-assets.js` | `FRONTEND_ROOT` (located via the exact `path.dirname(fileURLToPath(import.meta.url))` pattern `mcp/server.js` already established, resolved to the real `frontend/` directory). `resolveStaticAsset(requestPath)` — a PURE function, no fs/http access — resolving a request pathname against an explicit ALLOWLIST (`index.html`; `src/**/*.js`; top-level `styles/*.css` only, no nested subdirs) and returning `{ok:true, relativePath, contentType}` or `{ok:false, reason}`. This is a real allowlist, not merely a `../`-traversal guard: `test/`, `scripts/`, `package.json`, `README.md`, `CLAUDE.md`, `.gitignore` are all real files that exist on disk under `frontend/` and are REJECTED (`reason: 'not-allowlisted'`) even though a naive "stays inside frontend/" check would happily serve them. Traversal defense re-checks AFTER `path.posix.normalize` (never trusts normalization alone) for both literal and percent-encoded (`%2e%2e`) `..`; null bytes are rejected both literally and as a decoded `%00`; absolute-URL-looking input (a scheme, or `//host/...`) is rejected outright. `STATIC_CSP_HEADER_VALUE` is a SEPARATE, same-origin-permitting CSP (`script-src 'self'` etc., no `'unsafe-inline'`) from `security.js`'s `CSP_HEADER_VALUE` (`default-src 'none'`, unchanged, still used on every `/api/v1/*` response). |
|
|
29
|
+
| `http-server.js` (extended) | New static-asset branch, checked for every `GET` request whose path does NOT start with `/api/` — resolved BEFORE the session-token check (the ONE deliberate, disclosed exception to "every request requires a token": the token lives in a URL fragment the browser never sends, so the very first page-load request is structurally incapable of carrying it) but AFTER the Host-header check (T2 applies uniformly, auth or not). A rejected static path is a 404, never a 403 (never confirms a path's existence to a prober). Static responses carry `STATIC_CSP_HEADER_VALUE` + `Cache-Control: no-store` (unchanged) + the resolved Content-Type; `Access-Control-Allow-Origin` is still never set. The idle-auto-stop timer is deliberately NOT reset by an unauthenticated static-asset request — only genuine, authenticated `/api/v1/*` activity extends the session, unchanged from S1's own invariant. |
|
|
30
|
+
| `bin/agentic-security.js`'s `cmdExplore` (extended) | The printed URL changed from `http://127.0.0.1:<port>/api/v1/scan` to `http://127.0.0.1:<port>/#token=<64-hex-token>` — still the ONE place the token is ever displayed, now carried as a URL fragment instead of a separate "Token:" line, with updated wording ("open this URL in a browser" rather than "pass the token as a header manually"). |
|
|
31
|
+
| `frontend/src/lib/api-client.js` (new, in `frontend/`) | `extractTokenFromFragment()` (parses `location.hash` for `#token=<hex>`, tolerant of other hash params shell.js may also be writing) and `fetchGraph({token, baseUrl})` (fetches `/api/v1/graph` with the token header, unwraps the `.data` envelope field). `baseUrl` defaults to `''` (a same-origin relative fetch — the only thing the shipped page itself ever passes) and exists solely so a test can point this at a real running server without a headless browser. |
|
|
32
|
+
| `frontend/src/main.js` (new, in `frontend/`) | Replaces `index.html`'s old inline `<script type="module">` block (which would have needed `'unsafe-inline'` under `STATIC_CSP_HEADER_VALUE`) with an external module: extracts the token, calls `fetchGraph`, calls the EXISTING, UNCHANGED `bootstrap(root, graph)`, and shows a plain visible error in `#app-root` on fetch failure — never a silent blank page. |
|
|
33
|
+
| `test/server/static-assets.test.js` (new) | Pure-function tests — every real allowlisted path resolves with the right content type; several REAL on-disk-but-not-allowlisted files (`test/dom-shim.js`, `package.json`, `README.md`, `CLAUDE.md`, `.gitignore`, the generator script) are rejected; traversal (several encodings, including percent-encoded `..` and multi-segment escapes) and null-byte (literal and `%00`) rejection. |
|
|
34
|
+
| `test/server/http-server.test.js` (extended) | Real, live HTTP requests against a real running server for the new surface: `GET /` with NO token succeeds and serves the real `index.html`; `GET /api/v1/graph` with no token still 401 (the exemption is static-route-only); static responses carry the separate static CSP, never `default-src 'none'`; a forged `Host` header is still rejected on a static route; a live `../`-traversal attempt is rejected 404; the same on-disk-but-unlisted files are rejected 404 over real HTTP, never 403. |
|
|
35
|
+
| `test/server/cmd-explore.test.js` (updated) | Re-asserts the CLI's printed-URL regex against the new fragment-token form and that no separate "Token:" line is ever printed. |
|
|
36
|
+
| `frontend/test/api-client.test.js`, `frontend/test/live-fetch-parity.test.js` (new, in `frontend/`) | `extractTokenFromFragment`/`fetchGraph` unit tests (mocked fetch) plus the AC-16 proof: a REAL `explore` server started over the REAL flagship-graph fixture, fetched through the REAL `api-client.js` over a REAL loopback HTTP connection (no mock), asserted content-identical to the static-import baseline, then rendered through the SAME, UNCHANGED `bootstrap()` and asserted to produce a structurally IDENTICAL DOM tree to the existing static-import baseline's own render. |
|
|
37
|
+
|
|
38
|
+
## What this does NOT do
|
|
39
|
+
|
|
40
|
+
`POST /api/v1/export` (the self-contained-export pipeline, §17.5 — Milestone 4 territory per the parent M3 scoping doc). Any Milestone 4/5 decision-intelligence endpoint (`stories/`, `scenarios/`, `snapshots/`, `diffs/`, `obligations/`, `runtime/`, `recipients/`, `impact/`, `remediation/`, `rescan`). Wiring `frontend/` to this server (Sub-project Wire's own job — this server is built and tested standalone, in complete isolation from `frontend/`). File/line evidence path resolution and its own T4 path-traversal confinement test (no such lookup exists in S1's five endpoints — the graph JSON is served verbatim, so T4 correctly has no surface yet, the same honest status the threat model doc itself records). Triggering a scan from `explore` itself. Any language beyond what the graph itself already covers (this server is language-agnostic — it serves whatever graph exists).
|
|
41
|
+
|
|
42
|
+
## Conventions
|
|
43
|
+
|
|
44
|
+
- **No new npm dependency, ever, without re-opening this decision first.** `node:http`/`node:crypto`/`node:fs` only — matches this repo's own minimalism convention (`validate.js`'s own "no new npm dependency" precedent) and the MCP/LSP servers' own hand-rolled-protocol precedent. If a future increment genuinely needs a framework, that is a real, separately-justified decision, not a default.
|
|
45
|
+
- **CORS stays disabled by the ABSENCE of `Access-Control-Allow-Origin`.** Never add this header with a permissive value (`*`, or an echoed `Origin`) anywhere in this package without a real, disclosed threat-model reason — its absence is load-bearing security posture, not an oversight.
|
|
46
|
+
- **Every security-relevant string comparison goes through `constantTimeEqual`** (`security.js`), never `===`/`!==` on a token or secret.
|
|
47
|
+
- **A route handler's thrown error must never reach the client verbatim** — `http-server.js`'s own catch-and-genericize behavior around route dispatch is load-bearing; a future endpoint must not bypass it.
|